diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..4dac8af --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + process: 'readonly', + require: 'readonly', + module: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + console: 'readonly' + } + }, + rules: { + 'no-console': 'off', + 'no-unused-vars': ['error', { + 'argsIgnorePattern': '^_', + 'varsIgnorePattern': '^_' + }] + } +}; \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 553e714..7dfd89e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions -name: Run unit tests +name: CI on: push: @@ -11,22 +11,26 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run lint:cli - - run: npm run lint:src - - run: npm run build --if-present - - run: npm test + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint:cli + + - name: Run tests + run: npm test diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..49de95e --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,8 @@ +{ + "loader": "esm", + "experimental-modules": true, + "node-option": [ + "experimental-vm-modules", + "no-warnings" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index f952cd4..a20511d 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,227 @@ -# QuantCDN cli +# QuantCDN CLI -![Unit tests](https://github.com/quantcdn/quant-cli/actions/workflows/ci.yml/badge.svg) +Command line tools for QuantCDN. -Simplify deployments and interactions with the QuantCDN API by using the support cli tool. +## Installation -## Install - -The preferred method for installation is via npm. - -``` -npm i -g @quantcdn/quant-cli -``` - -or locally to a project - -``` -npm i -D @quantcdn/quant-cli +```bash +npm install -g @quantcdn/quant-cli ``` ## Usage -``` -$ quant - -Commands: - quant crawl [domain] Crawl and push an entire domain - quant delete Delete a deployed path from Quant - quant deploy [dir] Deploy the output of a static generator - quant file Deploy a single asset - quant info Give info based on current configuration - quant init Initialise a project in the current directory - quant page Make a local page asset available via Quant - quant proxy [status] Create a proxy to allow traffic directly to origin - [basicAuthUser] [basicAuthPass] - quant publish Publish an asset - quant purge Purge the cache for a given url - quant redirect [status] [author] Create a redirect - quant search Perform search index operations - quant unpublish Unpublish an asset - -Options: - --version Show version number [boolean] - --help Show help [boolean] - --clientid, -c Project customer id for QuantCDN [string] - --project, -p Project name for QuantCDN [string] - --token, -t Project token for QuantCDN [string] - --endpoint, -e API endpoint for QuantCDN [string] [default: "https://api.quantcdn.io"] -``` - -## Get started - -Please refer to the ["get started" guide](https://docs.quantcdn.io/docs/cli/get-started) for more details on getting set up. - -Quant accepts options or will ready configuration values from a `quant.json` file in the current directory. - -``` -$ quant init -``` - -An interactive walk-through for configuring your API connection. - -``` -$ quant info - -Endpoint: https://api.quantcdn.io/v1 -Customer: quant -Project: dev-docs -Token: **** -✅✅✅ Successfully connected to dev-docs -``` - -## Manage search index - -### Basic usage - -* Use `quant search status` to retrieve index size and basic configuration. -* Use `quant search unindex --path=/url/path` to remove an item from the index. -* Use `quant search clear` to clear the entire index. - -### Create and update records +The CLI can be used in two modes: + +### Interactive Mode +Simply run: +```bash +quant +``` +This will launch an interactive prompt that guides you through available commands and options. + +### CLI Mode +```bash +quant [options] +``` + +## Available Commands + +### Configuration +- `quant init` - Initialize a project in the current directory + ```bash + quant init [--dir=] + ``` + +- `quant info` - Show information about current configuration + +### Content Management +- `quant deploy [dir]` - Deploy the output of a static generator + ```bash + quant deploy [dir] [--attachments] [--skip-unpublish] [--skip-unpublish-regex=pattern] [--enable-index-html] [--chunk-size=10] [--force] + ``` + +- `quant file ` - Deploy a single asset + ```bash + quant file path/to/file.jpg /images/file.jpg + ``` + +- `quant page ` - Make a local page asset available + ```bash + quant page path/to/page.html /about-us [--enable-index-html] + ``` + +### Publishing Controls +- `quant publish ` - Publish an asset + ```bash + quant publish /about-us [--revision=latest] + ``` + +- `quant unpublish ` - Unpublish an asset + ```bash + quant unpublish /about-us + ``` + +- `quant delete ` - Delete a deployed path + ```bash + quant delete /about-us [--force] + ``` + +### Cache Management +- `quant purge ` - Purge the cache for a given URL or cache keys + ```bash + quant purge /about-us # Purge by path + quant purge "/*" # Purge all content + quant purge --cache-keys="key1 key2" # Purge by cache keys + quant purge /about-us --soft-purge # Mark as stale instead of deleting + ``` + +### Redirects +- `quant redirect [status]` - Create a redirect + ```bash + quant redirect /old-page /new-page [--status=301] + ``` + +### Edge Functions +- `quant function [uuid]` - Deploy an edge function + ```bash + quant function handler.js "My edge function" # Deploy new function + quant function handler.js "Updated function" 019361ae-2516-788a-8f50-e803ff561c34 # Update existing + ``` + +- `quant filter [uuid]` - Deploy an edge filter function + ```bash + quant filter filter.js "My edge filter" # Deploy new filter + quant filter filter.js "Updated filter" 019361ae-2516-788a-8f50-e803ff561c34 # Update existing + ``` + +- `quant auth [uuid]` - Deploy an edge auth function + ```bash + quant auth auth.js "My auth function" # Deploy new auth function + quant auth auth.js "Updated auth" 019361ae-2516-788a-8f50-e803ff561c34 # Update existing + ``` + +### Search +- `quant search ` - Perform search index operations + ```bash + quant search status # Show search index status + quant search index --path=records.json # Add/update search records + quant search unindex --path=/url/to/remove # Remove item from search index + quant search clear # Clear entire search index + ``` You may index new content or update existing content in the search index directly. Simply provide one or multiple records in JSON files. For example, consider a `search-records.json` file containing the following: -``` +```json [ { "title": "This is a record", "url": "/blog/page", "summary": "The record is small and neat.", - "content": "Lots of good content here. But not too much!", + "content": "Lots of good content here. But not too much!" }, { "title": "Fully featured search record", "url": "/about-us", "summary": "The record contains all the trimmings.", - "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras id dolor facilisis, ornare erat et, scelerisque odio. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "image": "https://www.example.com/images/about.jpg", "categories": [ "Blog", "Commerce", "Jamstack" ], - "tags": [ "Tailwind" , "QuantCDN" ] + "tags": [ "Tailwind" , "QuantCDN" ], + "author": "John Doe", + "publishDate": "2024-02-22", + "readTime": "5 mins", + "customField": "Any value you need" } ] ``` -To post these records to the search index: -``` -quant search index --path=./search-records.json +Required fields for each record: +- `title`: The title of the page +- `url`: The URL path of the page +- `content`: The searchable content + +Common optional fields: +- `summary`: A brief description +- `image`: URL to an associated image +- `categories`: Array of category names +- `tags`: Array of tag names + +You can include any additional key/value pairs in your records. These custom fields will be indexed and available for filtering, faceting, or display in your search integration. + +### Validation +- `quant scan` - Validate local file checksums + ```bash + quant scan [--diff-only] [--unpublish-only] [--skip-unpublish-regex=pattern] + ``` + +### WAF Logs +- `quant waf:logs` - Access project WAF logs + ```bash + quant waf:logs [--fields=field1,field2] [--output=file.csv] [--all] [--size=10] + ``` + +## Global Options +These options can be used with any command: + +```bash +--clientid, -c Project customer id for QuantCDN +--project, -p Project name for QuantCDN +--token, -t Project token for QuantCDN +--endpoint, -e API endpoint for QuantCDN (default: "https://api.quantcdn.io/v1") ``` -**Note:** The path may either refer to an individual file or a path on disk containing multiple JSON files. +## Configuration -## Testing +The CLI can be configured using either: +1. Interactive initialization: `quant init` +2. Command line arguments (see Global Options) +3. Environment variables: + - `QUANT_CLIENT_ID` + - `QUANT_PROJECT` + - `QUANT_TOKEN` + - `QUANT_ENDPOINT` +4. Configuration file: `quant.json` in the current directory -Automated via CodeFresh for all PRs and mainline branches. +Missing configuration will be handled differently depending on the context: +- Running `quant` with no arguments will prompt to initialize a new project +- Running specific commands without configuration will show detailed setup instructions +## Examples + +```bash +# Initialize a new project +quant init + +# Deploy a directory +quant deploy build --attachments + +# Upload a single file +quant file ./logo.png /images/logo.png + +# Create a redirect +quant redirect /old-page /new-page --status=301 + +# Purge cache with various options +quant purge "/*" # Purge all content +quant purge --cache-keys="key1 key2" # Purge specific cache keys +quant purge /about --soft-purge # Soft purge a path + +# Deploy edge functions +quant function handler.js "My edge function" # Deploy a new function +quant auth auth.js "My auth function" # Deploy an auth function +quant filter filter.js "My edge filter" # Deploy a filter function + +# Check deployment status +quant scan --diff-only ``` -$ npm run lint -$ npm run test + +## Testing + +```bash +npm run lint +npm run test ``` ## Contributing -Issues and feature requests are managed via Github and pull requests are welcomed. +Issues and feature requests are managed via Github and pull requests are welcomed. \ No newline at end of file diff --git a/cli.js b/cli.js index 17a802e..1576c51 100755 --- a/cli.js +++ b/cli.js @@ -1,11 +1,110 @@ #!/usr/bin/env node -require('yargs/yargs')(process.argv.slice(2)) +const { intro, outro, select, confirm, isCancel } = require('@clack/prompts'); +const color = require('picocolors'); +const { getCommandOptions, getCommand, loadCommands } = require('./src/commandLoader'); +const config = require('./src/config'); +const yargs = require('yargs'); + +async function showActiveConfig() { + // Try to load config first + const args = process.argv.slice(2); + const yargsInstance = yargs(args); + const argv = await yargsInstance.parse(); + + await config.fromArgs(argv, true); + + const endpoint = config.get('endpoint'); + const clientId = config.get('clientid'); + const project = config.get('project'); + const defaultEndpoint = 'https://api.quantcdn.io/v1'; + + console.log(color.dim('─────────────────────────────────────')); + console.log(color.dim('Active configuration:')); + console.log(color.dim(`Organization: ${clientId || 'Not set'}`)); + console.log(color.dim(`Project: ${project || 'Not set'}`)); + if (endpoint && endpoint !== defaultEndpoint) { + console.log(color.dim(`Endpoint: ${endpoint}`)); + } + console.log(color.dim('─────────────────────────────────────')); +} + +async function interactiveMode() { + intro(color.bgCyan(color.white(' QuantCDN CLI '))); + + try { + // Check for config before showing menu + if (!await config.fromArgs({ _: [''] }, true)) { + const shouldInit = await confirm({ + message: 'No configuration found. Would you like to initialize a new project?', + initialValue: true + }); + + if (isCancel(shouldInit) || !shouldInit) { + outro(color.yellow('Configuration required to continue. You can:')); + outro(color.yellow('1. Run "quant init" to create a new configuration')); + outro(color.yellow('2. Create a quant.json file in this directory')); + outro(color.yellow('3. Set environment variables (QUANT_CLIENT_ID, QUANT_PROJECT, QUANT_TOKEN)')); + outro(color.yellow('4. Provide configuration via command line arguments')); + process.exit(0); + } + + // Get the init command and run it + const initCommand = getCommand('init'); + const initArgs = await initCommand.promptArgs(); + if (!initArgs) { + outro(color.yellow('Operation cancelled')); + process.exit(0); + } + await initCommand.handler(initArgs); + } + + await showActiveConfig(); + + const commandOptions = getCommandOptions(); + const command = await select({ + message: 'What would you like to do?', + options: commandOptions + }); + + if (isCancel(command)) { + outro('Operation cancelled'); + process.exit(0); + } + + const cmd = getCommand(command); + if (!cmd) { + outro('Invalid command selected'); + process.exit(1); + } + + const args = await cmd.promptArgs(); + if (!args) { + outro('Operation cancelled'); + process.exit(0); + } + + try { + const result = await cmd.handler(args); + if (result) { + console.log(result); + } + outro('Operation completed successfully'); + } catch (err) { + outro(color.red('Error: ' + err.message)); + process.exit(1); + } + } catch (err) { + outro(color.red('Error: ' + err.message)); + process.exit(1); + } +} + +function cliMode() { + let yargsInstance = yargs(process.argv.slice(2)) .strict() .help() - .commandDir('src/commands') - // Global options that can be used by all commands - // options provided will be used over quant.json. + // Global options .option('clientid', { alias: 'c', describe: 'Project customer id for QuantCDN', @@ -25,16 +124,36 @@ require('yargs/yargs')(process.argv.slice(2)) alias: 'e', describe: 'API endpoint for QuantCDN', type: 'string', - }) - .option('bearer', { - describe: 'Scoped API berarer token', - type: 'string', - }) - .demandCommand() - .wrap(100) - .argv; - -process.on('SIGINT', function() { - console.log('Caught interrupt signal'); - process.exit(); -}); + }); + + // Add all commands to yargs + const commands = loadCommands(); + Object.entries(commands).forEach(([_name, command]) => { + yargsInstance = yargsInstance.command({ + command: command.command, + describe: command.describe, + builder: command.builder, + handler: async (argv) => { + try { + await showActiveConfig(); + const result = await command.handler(argv); + if (result) { + console.log(result); + } + process.exit(0); + } catch (err) { + console.error(color.red('Error: ' + err.message)); + process.exit(1); + } + } + }); + }); + + yargsInstance.parse(); +} + +if (process.argv.length > 2) { + cliMode(); +} else { + interactiveMode(); +} diff --git a/package-lock.json b/package-lock.json index 41a8ab7..12f001f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "4.0.0", "license": "ISC", "dependencies": { + "@clack/core": "^0.3.3", + "@clack/prompts": "^0.7.0", "axios": "^1.7.3", "big-json": "^3.1.0", "chalk": "^4.1.0", @@ -17,120 +19,110 @@ "md5-file": "^5.0.0", "mime-types": "^2.1.27", "papaparse": "^5.4.1", - "prompt": "^1.1.0", - "simplecrawler": "^1.1.9", + "picocolors": "^1.0.0", "string.prototype.matchall": "^4.0.2", - "tmp": "^0.2.1", "yargs": "^17.0.1" }, "bin": { "quant": "cli.js" }, "devDependencies": { - "@sinonjs/referee": "^11.0.1", - "chai": "^5.1.1", - "chai-as-promised": "^7.1.1", - "eslint": "^9.8.0", - "eslint-config-google": "^0.14.0", - "eslint-plugin-jsdoc": "^50.0.1", - "mocha": "^10.0.0", - "mock-require": "^3.0.3", - "sinon": "^18.0.0", + "axios-mock-adapter": "^2.1.0", + "c8": "^8.0.1", + "chai": "^5.1.0", + "eslint": "^8.0.0", + "mocha": "^10.3.0", + "nock": "^13.5.4", + "sinon": "^17.0.1", "sinon-chai": "^4.0.0" }, "engines": { "node": ">=16" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@clack/core": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.4.tgz", + "integrity": "sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==", "license": "MIT", - "engines": { - "node": ">=0.1.90" + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" } }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.48.0.tgz", - "integrity": "sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==", - "dev": true, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], "license": "MIT", "dependencies": { - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, - "engines": { - "node": ">=16" + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", - "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.4", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", + "espree": "^9.6.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -138,30 +130,36 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10.10.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -178,19 +176,13 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", - "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } + "license": "BSD-3-Clause" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -209,31 +201,42 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, - "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==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@nodelib/fs.scandir": { @@ -274,29 +277,6 @@ "node": ">= 8" } }, - "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==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -308,62 +288,62 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@sinonjs/referee": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/referee/-/referee-11.0.1.tgz", - "integrity": "sha512-slA8klGmJskx/A2CBB/c3yOm8+gBzK64b7hoa3v+j7+dV34F1UI+ABB9tPZOEKcMSrMGZe7hC2Ebq2tnpgUGNA==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0", - "@sinonjs/samsam": "^8.0.0", - "event-emitter": "^0.3.5", - "lodash.isarguments": "^3.1.0", - "util": "^0.12.5" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^2.0.0", + "@sinonjs/commons": "^3.0.1", "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" } }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", "dev": true, "license": "(Unlicense OR Apache-2.0)" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true, + "license": "ISC" + }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", "bin": { @@ -411,12 +391,15 @@ } }, "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==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { @@ -448,16 +431,6 @@ "node": ">= 8" } }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -522,12 +495,6 @@ "node": ">=12" } }, - "node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -550,9 +517,9 @@ } }, "node_modules/axios": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", - "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -560,6 +527,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -624,6 +605,64 @@ "dev": true, "license": "ISC" }, + "node_modules/c8": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz", + "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "rimraf": "^3.0.2", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/c8/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/c8/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==", + "dev": true, + "license": "ISC" + }, + "node_modules/c8/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -667,9 +706,9 @@ } }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", "dev": true, "license": "MIT", "dependencies": { @@ -683,29 +722,6 @@ "node": ">=12" } }, - "node_modules/chai-as-promised": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.2.tgz", - "integrity": "sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "check-error": "^1.0.2" - }, - "peerDependencies": { - "chai": ">= 2.1.2 < 6" - } - }, - "node_modules/chai/node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -723,16 +739,13 @@ } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -787,6 +800,15 @@ "node": ">=12" } }, + "node_modules/cliui/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": ">=8" + } + }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -807,6 +829,18 @@ "node": ">=8" } }, + "node_modules/cliui/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" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -842,15 +876,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -863,16 +888,6 @@ "node": ">= 0.8" } }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -880,6 +895,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -887,9 +909,9 @@ "license": "MIT" }, "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==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -900,28 +922,6 @@ "node": ">= 8" } }, - "node_modules/cycle": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", - "integrity": "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dev": true, - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -974,13 +974,13 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1074,6 +1074,19 @@ "node": ">=0.3.1" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1087,9 +1100,9 @@ "license": "MIT" }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -1107,7 +1120,7 @@ "function.prototype.name": "^1.1.6", "get-intrinsic": "^1.2.4", "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", + "globalthis": "^1.0.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.3", @@ -1123,10 +1136,10 @@ "is-string": "^1.0.7", "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", + "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.2", "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.9", @@ -1167,13 +1180,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", @@ -1217,53 +1223,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "dev": true, - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dev": true, - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -1283,38 +1246,43 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.11.0", - "@eslint/config-array": "^0.17.1", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", + "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.0.2", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.1.0", - "esquery": "^1.5.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", @@ -1328,55 +1296,16 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/eslint-config-google": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", - "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "eslint": ">=5.16.0" - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "50.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.2.1.tgz", - "integrity": "sha512-KbGhcct6JxzM0x1gjqH1hf4vvc+YNMag5JXyMuPFIPP9THWctRg3UgBUjNcI6a6Rw+1GdKeJ3vTmSICLVF0mtw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.48.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.3.6", - "escape-string-regexp": "^4.0.0", - "espree": "^10.1.0", - "esquery": "^1.6.0", - "parse-imports": "^2.1.1", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-scope": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", - "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1384,54 +1313,61 @@ "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "node_modules/eslint/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==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/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==", + "dev": true, + "license": "MIT", "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/espree": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", - "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.12.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.0.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1483,35 +1419,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dev": true, - "license": "ISC", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1544,16 +1451,16 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=16.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/fill-range": { @@ -1597,30 +1504,31 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.4" + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">=16" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true, "license": "ISC" }, "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==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -1663,9 +1571,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -1744,23 +1652,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true, - "license": "ISC" - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -1858,13 +1749,16 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1898,6 +1792,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -2005,22 +1906,17 @@ ], "license": "MIT" }, - "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2099,23 +1995,6 @@ "node": ">=8" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -2173,6 +2052,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -2234,22 +2137,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2433,16 +2320,49 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/jackspeak": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", - "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", + "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -2452,9 +2372,6 @@ }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/js-yaml": { @@ -2470,16 +2387,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", - "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2507,6 +2414,13 @@ "integrity": "sha512-gIPoa6K5w6j/RnQ3fOtmvICKNJGViI83A7dnTIL+0QJ/1GKuNvCPFvbFWxt0agruF4iGgDFJvge4Gua4ZoiggQ==", "license": "MIT" }, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -2579,12 +2493,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -2592,13 +2500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2624,24 +2525,37 @@ } }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", - "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", "license": "ISC", "engines": { "node": "20 || >=22" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/md5-file": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", @@ -2698,9 +2612,9 @@ } }, "node_modules/mocha": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.0.tgz", - "integrity": "sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA==", + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2733,6 +2647,16 @@ "node": ">= 14.0.0" } }, + "node_modules/mocha/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/mocha/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -2806,13 +2730,6 @@ "node": ">=10" } }, - "node_modules/mocha/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/mocha/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2828,6 +2745,19 @@ "node": ">=8" } }, + "node_modules/mocha/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2881,46 +2811,13 @@ "node": ">=10" } }, - "node_modules/mock-require": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", - "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-caller-file": "^1.0.2", - "normalize-path": "^2.1.1" - }, - "engines": { - "node": ">=4.3.0" - } - }, - "node_modules/mock-require/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "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/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2928,17 +2825,10 @@ "dev": true, "license": "MIT" }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true, - "license": "ISC" - }, "node_modules/nise": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", - "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2949,6 +2839,21 @@ "path-to-regexp": "^6.2.1" } }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2960,9 +2865,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3067,9 +2972,9 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "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==", "license": "BlueOak-1.0.0" }, "node_modules/papaparse": { @@ -3080,29 +2985,15 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-imports": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.1.1.tgz", - "integrity": "sha512-TDT4HqzUiTMO1wJRwg/t/hYk8Wdp3iF/ToMIlAoVQfL1Xs/sTxq1dKWSMjMbQmIarfWKymOyly40+zmPHXMqCA==", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "es-module-lexer": "^1.5.3", - "slashes": "^3.0.12" + "callsites": "^3.0.0" }, "engines": { - "node": ">= 18" + "node": ">=6" } }, "node_modules/path-exists": { @@ -3115,6 +3006,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3141,9 +3042,9 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, @@ -3157,6 +3058,12 @@ "node": ">= 14.16" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -3195,20 +3102,14 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/prompt": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/prompt/-/prompt-1.3.0.tgz", - "integrity": "sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg==", + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "async": "3.2.3", - "read": "1.0.x", - "revalidator": "0.1.x", - "winston": "2.x" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 8" } }, "node_modules/proxy-from-env": { @@ -3258,18 +3159,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", - "license": "ISC", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3299,15 +3188,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" + "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -3316,13 +3205,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true, - "license": "ISC" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3353,20 +3235,44 @@ "node": ">=0.10.0" } }, - "node_modules/revalidator": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", - "integrity": "sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg==", - "license": "Apache 2.0", - "engines": { - "node": ">= 0.4.0" + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/robots-parser": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-2.4.0.tgz", - "integrity": "sha512-oO8f2SI04dJk3pbj2KOMJ4G6QfPAgqcGmrYGmansIcpRewIPT2ljWEt5I+ip6EgiyaLo+RXkkUWw74M25HDkMA==", - "license": "MIT" + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/run-parallel": { "version": "1.2.0", @@ -3439,12 +3345,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -3551,37 +3451,19 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simplecrawler": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/simplecrawler/-/simplecrawler-1.1.9.tgz", - "integrity": "sha512-IY5YmeJWvfc1zpy9so1p/EknCqNum3Y9tmnzuLWZqKEwbntGXPGvN0SOtr+XqT4BHjfek2C12g3Tg1yK7Hoh8g==", - "license": "BSD-2-Clause", - "dependencies": { - "async": "^3.1.0", - "iconv-lite": "^0.5.0", - "robots-parser": "^2.1.1", - "urijs": "^1.19.1" - }, - "bin": { - "crawl": "lib/cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sinon": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", - "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.1", + "@sinonjs/commons": "^3.0.0", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", - "diff": "^5.2.0", - "nise": "^6.0.0", - "supports-color": "^7" + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" }, "funding": { "type": "opencollective", @@ -3599,46 +3481,11 @@ "sinon": ">=4.0.0" } }, - "node_modules/slashes": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", - "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", - "dev": true, - "license": "ISC" - }, - "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==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.18", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", - "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" }, "node_modules/string_decoder": { "version": "1.1.1", @@ -3681,37 +3528,31 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/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": ">=8" + } + }, "node_modules/string-width-cjs/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==", "license": "MIT" }, - "node_modules/string-width/node_modules/ansi-regex": { + "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/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==", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/string.prototype.matchall": { @@ -3790,15 +3631,18 @@ } }, "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==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-ansi-cjs": { @@ -3814,6 +3658,15 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/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": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3839,21 +3692,41 @@ "node": ">=8" } }, - "node_modules/synckit": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", - "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "*" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-table": { @@ -3879,15 +3752,6 @@ "readable-stream": "2 || 3" } }, - "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3901,20 +3765,6 @@ "node": ">=8.0" } }, - "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "dev": true, - "license": "ISC" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3938,6 +3788,19 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", @@ -4036,32 +3899,27 @@ "punycode": "^2.1.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "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==", "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4112,32 +3970,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/winston": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", - "integrity": "sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==", - "license": "MIT", - "dependencies": { - "async": "^2.6.4", - "colors": "1.0.x", - "cycle": "1.0.x", - "eyes": "0.1.x", - "isstream": "0.1.x", - "stack-trace": "0.0.x" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/winston/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4190,6 +4022,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/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": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4210,16 +4051,16 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "engines": { + "node": ">=8" } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { @@ -4234,21 +4075,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4308,6 +4134,15 @@ "node": ">=10" } }, + "node_modules/yargs/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": ">=8" + } + }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4337,6 +4172,18 @@ "node": ">=8" } }, + "node_modules/yargs/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" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index 460ead6..c9cb643 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,22 @@ "node": ">=16" }, "scripts": { - "test": "mocha --recursive", - "mocha": "mocha", - "lint:cli": "eslint cli.js", - "lint:src": "eslint src", - "lint": "npm run lint:cli && npm run lint:src" + "test": "mocha 'tests/**/**/*.test.mjs'", + "test:watch": "mocha 'tests/**/**/*.test.mjs' --watch", + "test:coverage": "c8 mocha 'tests/**/**/*.test.mjs'", + "test:config": "mocha 'tests/unit/config.test.mjs'", + "test:deploy": "mocha 'tests/unit/commands/deploy.test.mjs'", + "test:file": "mocha 'tests/unit/commands/file.test.mjs'", + "test:page": "mocha 'tests/unit/commands/page.test.mjs'", + "test:redirect": "mocha 'tests/unit/commands/redirect.test.mjs'", + "test:purge": "mocha 'tests/unit/commands/purge.test.mjs'", + "test:unpublish": "mocha 'tests/unit/commands/unpublish.test.mjs'", + "test:delete": "mocha 'tests/unit/commands/delete.test.mjs'", + "test:functions": "mocha 'tests/unit/commands/functions.test.mjs'", + "lint:cli": "eslint --config .eslintrc.js cli.js", + "lint:src": "eslint --config .eslintrc.js src", + "lint:tests": "eslint --config tests/.eslintrc.js tests", + "lint": "npm run lint:cli && npm run lint:src && npm run lint:tests" }, "repository": { "type": "git", @@ -35,6 +46,8 @@ "/src" ], "dependencies": { + "@clack/core": "^0.3.3", + "@clack/prompts": "^0.7.0", "axios": "^1.7.3", "big-json": "^3.1.0", "chalk": "^4.1.0", @@ -43,22 +56,18 @@ "md5-file": "^5.0.0", "mime-types": "^2.1.27", "papaparse": "^5.4.1", - "prompt": "^1.1.0", - "simplecrawler": "^1.1.9", + "picocolors": "^1.0.0", "string.prototype.matchall": "^4.0.2", - "tmp": "^0.2.1", "yargs": "^17.0.1" }, "devDependencies": { - "@sinonjs/referee": "^11.0.1", - "chai": "^5.1.1", - "chai-as-promised": "^8.0.0", - "eslint": "^9.8.0", - "eslint-config-google": "^0.14.0", - "eslint-plugin-jsdoc": "^50.0.1", - "mocha": "^10.0.0", - "mock-require": "^3.0.3", - "sinon": "^19.0.2", - "sinon-chai": "^4.0.0" + "axios-mock-adapter": "^2.1.0", + "c8": "^8.0.1", + "chai": "^5.1.0", + "mocha": "^10.3.0", + "nock": "^13.5.4", + "sinon": "^17.0.1", + "sinon-chai": "^4.0.0", + "eslint": "^8.0.0" } } diff --git a/src/commandLoader.js b/src/commandLoader.js new file mode 100644 index 0000000..4904f05 --- /dev/null +++ b/src/commandLoader.js @@ -0,0 +1,72 @@ +function loadCommands() { + const commands = { + // Primary deployment commands + 'deploy': require('./commands/deploy'), + 'page': require('./commands/page'), + 'file': require('./commands/file'), + 'redirect': require('./commands/redirect'), + 'purge': require('./commands/purge'), + 'waflogs': require('./commands/waflogs'), + 'search': require('./commands/search'), + 'scan': require('./commands/scan'), + + // Edge functions + 'function': require('./commands/function'), + 'filter': require('./commands/function_filter'), + 'auth': require('./commands/function_auth'), + 'functions': require('./commands/functions'), + + // Destructive operations + 'unpublish': require('./commands/unpublish'), + 'delete': require('./commands/delete'), + + // Project management + 'info': require('./commands/info'), + 'init': require('./commands/init'), + }; + + return commands; +} + +function getCommandOptions() { + return [ + // Primary deployment commands + { value: 'deploy', label: 'Deploy an entire directory' }, + { value: 'page', label: 'Deploy a single page' }, + { value: 'file', label: 'Deploy a single file' }, + { value: 'redirect', label: 'Create a redirect' }, + { value: 'purge', label: 'Purge the cache for a path' }, + { value: 'waflogs', label: 'Access WAF logs' }, + { value: 'search', label: 'Perform search index operations' }, + { value: 'scan', label: 'Validate local file checksums' }, + + // Visual separator + { value: 'separator1', label: '───────────────────────', disabled: true }, + + // Edge functions + { value: 'function', label: 'Deploy an edge function' }, + { value: 'filter', label: 'Deploy an edge filter' }, + { value: 'auth', label: 'Deploy an edge auth function' }, + { value: 'functions', label: 'Deploy multiple edge functions from JSON' }, + + // Visual separator + { value: 'separator2', label: '───────────────────────', disabled: true }, + + // Destructive operations + { value: 'unpublish', label: 'Unpublish an asset' }, + { value: 'delete', label: 'Delete an asset' }, + + // Visual separator + { value: 'separator3', label: '───────────────────────', disabled: true }, + + // Project management + { value: 'info', label: 'Show project info' }, + { value: 'init', label: 'Reinitialize project settings' }, + ]; +} + +module.exports = { + loadCommands, + getCommandOptions, + getCommand: (name) => loadCommands()[name] +}; \ No newline at end of file diff --git a/src/commands/crawl.js b/src/commands/crawl.js deleted file mode 100644 index c195a90..0000000 --- a/src/commands/crawl.js +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Crawl and push an entire website to Quant. - * - * @usage - * quant crawl -d https://www.google.com/ - */ -const chalk = require('chalk'); -const config = require('../config'); -const client = require('../quant-client'); -const crawler = require('simplecrawler'); -const {write} = require('../helper/resumeState'); -const axios = require('axios') -const util = require('util'); -const fs = require('fs'); -const tmp = require('tmp'); -const prompt = require('prompt'); -const os = require('os'); - -const {redirectHandler} = require('../crawl/callbacks'); - -const filters = require('../crawl/filters'); -const detectors = require('../crawl/detectors'); - -let crawl; -let count = 0; -let writingState = false; -let filename; - -const failures = []; - -const command = {}; - -command.command = 'crawl '; -command.describe = 'Crawl and push an entire domain'; -command.builder = { - 'rewrite': { - describe: 'Rewrite host patterns', - alias: 'r', - type: 'boolean', - default: false, - }, - 'attachments': { - describe: 'Find attachments', - alias: 'a', - type: 'boolean', - default: false, - }, - 'interval': { - describe: 'Crawl interval', - alias: 'i', - type: 'integer', - default: 200, - }, - 'cookies': { - describe: 'Accept cookies during the crawl', - alias: 'c', - type: 'boolean', - default: false, - }, - 'concurrency': { - describe: 'Crawl concurrency', - alias: 'n', - type: 'integer', - default: 4, - }, - 'size': { - describe: 'Crawl resource buffer size in bytes', - alias: 's', - type: 'integer', - default: 268435456, - }, - 'robots': { - describe: 'Respect robots', - type: 'boolean', - default: false, - }, - 'extra-domains': { - describe: 'CSV of additional host names to fan out to', - alias: 'e', - }, - 'skip-resume': { - describe: 'Start a fresh crawl ignoring resume state', - type: 'boolean', - default: false, - }, - 'no-interaction': { - describe: 'No user interaction', - type: 'boolean', - default: false, - }, - 'urls-file': { - describe: 'JSON file containing array of URLs to add to the queue', - type: 'string', - }, - 'seed-notfound': { - describe: 'Send the content of unique not found pages to quant', - type: 'boolean', - default: false, - }, - 'user-agent': { - describe: 'The user-agent to send with the request', - type: 'string', - default: 'Quant (+http://api.quantcdn.io)', - }, -}; - -/** - * When the operator interrupts the process, store the - * state of the crawler. - */ -[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `uncaughtException`, `SIGTERM`].forEach((eventType) => { - process.on(eventType, function() { - if (typeof crawl != 'undefined' && !writingState) { - writingState = true; - // Saving the state cannot be async as exit hooks don't correctly - // execute async callbacks. - write(crawl, filename); - } - }); -}); - -command.handler = async function(argv) { - console.log(chalk.bold.green('*** Quant crawl ***')); - - // Make sure configuration is loaded. - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } - - // Set the resume-state filename. - filename = `${config.get('clientid')}-${config.get('project')}`; - - const domain = argv.domain; - - if (!domain) { - console.log('Missing required parameter: ' + chalk.red('[domain]')); - return; - } - - // Queue URLs from urls-file if provided. - urlsdata = []; - if (argv['urls-file'] && argv['urls-file'].length > 0) { - try { - urlsdata = JSON.parse(fs.readFileSync(argv['urls-file'])); - } catch (error) { - console.log(chalk.bold.red('❌ ERROR: Cannot read urls-file: ' + argv['urls-file'])); - process.exit(1); - } - } - - crawl = crawler(domain); - crawl.interval = argv.interval; - crawl.decodeResponses = true; - crawl.maxResourceSize = argv.size; // 256MB - crawl.maxConcurrency = argv.concurrency; - crawl.respectRobotsTxt = argv.robots; - crawl.acceptCookies = argv.cookies; - crawl.customHeaders = { - 'User-Agent': argv['user-agent'], - }; - - const quant = client(config); - - // Get the domain host. - let hostname = domain; - - if (hostname.indexOf('//') > -1) { - hostname = hostname.split('/')[2]; - } else { - hostname = hostname.split('/')[0]; - } - - // Prepare the hostname. - hostname = hostname.split(':')[0]; - hostname = hostname.split('?')[0]; - - crawl.domainWhitelist = [ - hostname, - ]; - - if (argv['extra-domains']) { - crawl.domainWhitelist.push(argv['extra-domains'].split(',').map((d) => d.trim())); - } - - const fetchCallback = async function(queueItem, responseBuffer, response) { - const extraItems = []; - - // Prepare the detectors - attempt to locate additional requests to add - // to the queue based on patterns in the DOMString. - - for (const [n, detector] of Object.entries(detectors)) { - if (detector.applies(response)) { - await detector.handler(responseBuffer, queueItem.host, queueItem.protocol).map((i) => extraItems.push(i)); - } - } - - extraItems.forEach((item) => crawl.queueURL(item, queueItem.referrer)); - - // Cheap strip of domain. - const url = queueItem.url.replace(domain, ''); - const buffer = Buffer.from(responseBuffer, 'utf8'); - - if (response.headers['content-type'] && response.headers['content-type'].includes('text/html')) { - let content = buffer.toString(); - - - for (const [name, filter] of Object.entries(filters)) { - if (!argv.hasOwnProperty(filter.option)) { - // Filters must have an option to toggle them - if the option is - // not defined we skip this filter. - continue; - } - if (argv[filter.option]) { - content = filter.handler(content, queueItem, argv); - } - } - console.log(chalk.bold.green('✅ MARKUP:') + ` ${url}`); - - try { - await quant.markup(Buffer.from(content), url, true, argv.attachments); - } catch (err) {} - } else { - // @TODO: Identify why the file needs to be downloaded twice is - - // it looks to only affect some files, it seems PNG is affected but - // not all files. - const tmpfile = tmp.fileSync(); - const file = fs.createWriteStream(tmpfile.name); - const response = await axios.get(queueItem.url, { responseBuffer: 'arrayBuffer' }); - - if (!response.data || response.data.byteLength < 50) { - queueItem.status = 'failed'; - file.close(); - } - - const asset = Buffer.from(response.data, 'utf8'); - const extraHeaders = {}; - - fs.writeFileSync(tmpfile.name, asset); - - // Disposition headers. - ['content-disposition', 'content-type'].map((i) => { - if (Object.keys(queueItem.stateData.headers).includes(i)) { - extraHeaders[i] = queueItem.stateData.headers[i]; - } - }); - - console.log(chalk.bold.green('✅ FILE:') + ` ${url}`); - try { - await quant.file(tmpfile.name, url, true, extraHeaders); - } catch (err) { - console.log(err); - } - - // Remove temporary file immediately. - fs.unlinkSync(tmpfile.name); - } - count++; - }; - - if (hostname.startsWith('www.')) { - crawl.domainWhitelist.push(hostname.slice(4)); - } - - crawl.on('complete', function() { - console.log(chalk.bold.green('✅ All done! ') + ` ${count} total items.`); - console.log(chalk.bold.green('Failed items:')); - console.log(failures); - write(crawl, filename); - }); - - // Handle sending redirects to the Quant API. - crawl.on('fetchredirect', (item, redirect, response) => redirectHandler(quant, item, redirect)); - - // Capture errors. - crawl.on('fetcherror', function(queueItem, response) { - console.log(chalk.bold.red('❌ ERROR:') + ` ${queueItem.stateData.code} for ${queueItem.url}`); - failures.push({'code': queueItem.stateData.code, 'url': queueItem.url}); - if (queueItem.stateData.code == 403) { - console.log('403'); - } - }); - - crawl.on('fetch404', async function(queueItem, response) { - if (argv['seed-notfound']) { - response.on('data', async function(buffer) { - await fetchCallback(queueItem, buffer, response); - }); - } - }); - - crawl.on('fetchcomplete', async function(queueItem, responseBuffer, response) { - await fetchCallback(queueItem, responseBuffer, response); - }); - - if (!argv['skip-resume']) { - let result; - - if (!fs.existsSync(`${os.homedir()}/.quant/${filename}`)) { - result = {resume: false}; - } else if (!argv['no-interaction']) { - prompt.start(); - result = await prompt.get({ - properties: { - resume: { - required: true, - description: 'Resume from the previous crawl?', - default: true, - type: 'boolean', - }, - }, - }); - } else { - result = {resume: true}; - } - - if (result.resume) { - // Prevent manual URL list when resuming. - if (urlsdata.length > 0) { - console.log(chalk.bold.green('❌ ERROR: Cannot use --urls-file while resuming, ignoring.')); - urlsdata = []; - } - - // Defrost is async and supports non-existent files. - crawl.queue.defrost(`${os.homedir()}/.quant/${filename}`, (err) => { - console.log(chalk.bold.green('✅ DONE: Loaded resume state from ' + `${os.homedir()}/.quant/${filename}`)); - }); - } - } else { - console.log(chalk.bold.green('Skipping resume state via --skip-resume.')); - } - - // Inject URLs provided via urls-file. - if (urlsdata.length > 0) { - for (const url of urlsdata) { - crawl.queueURL(url); - } - } - - crawl.start(); -}; - -module.exports = command; diff --git a/src/commands/delete.js b/src/commands/delete.js index 77a92c4..512d27c 100644 --- a/src/commands/delete.js +++ b/src/commands/delete.js @@ -2,72 +2,127 @@ * Delete content from the Quant API. * * @usage - * quant delete + * quant delete */ -const chalk = require('chalk'); +const { text, confirm, isCancel } = require('@clack/prompts'); +const color = require('picocolors'); const config = require('../config'); const client = require('../quant-client'); -const yargs = require('yargs'); -const prompt = require('prompt'); -const command = {}; +const command = { + command: 'delete ', + describe: 'Delete a deployed path from Quant', + + builder: (yargs) => { + return yargs + .positional('path', { + describe: 'Deployed asset path to remove', + type: 'string', + demandOption: true + }) + .option('force', { + alias: 'f', + type: 'boolean', + description: 'Delete the asset without confirmation', + default: false + }); + }, -command.command = 'delete [force]'; -command.describe = 'Delete a deployed path from Quant'; -command.builder = (yargs) => { - yargs.positional('path', { - describe: 'Deployed asset path to remove', - type: 'string', - }); - yargs.option('force', { - alias: 'f', - type: 'boolean', - description: 'Delete the asset without interaction.', - }); -}; + async promptArgs(providedArgs = {}) { + let path = providedArgs.path; + if (!path) { + path = await text({ + message: 'Enter the deployed asset path to remove', + validate: value => !value ? 'Path is required' : undefined + }); + if (isCancel(path)) return null; + } -command.handler = async (argv) => { - const path = argv.path; + // Only ask for confirmation in promptArgs if we're in interactive mode + if (!providedArgs.force && !process.argv.slice(2).length) { + const shouldDelete = await confirm({ + message: 'This will delete all revisions of this asset from QuantCDN. Are you sure?', + initialValue: false, + active: 'Yes', + inactive: 'No' + }); + if (isCancel(shouldDelete) || !shouldDelete) return null; + } - console.log(chalk.bold.green('*** Quant delete ***')); + return { path, force: providedArgs.force }; + }, - if (!config.fromArgs(argv)) { - console.log(chalk.yellow('Quant is not configured, run init.')); - yargs.exit(1); - } + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } - if (!argv.force) { - prompt.start(); - const schema = { - properties: { - force: { - required: true, - description: 'This will delete all revisions of an asset from QuantCDN', - pattern: /(y|yes|n|no)/, - default: 'no', - message: 'Only yes or no is allowed.', - }, - }, + const context = { + config: this.config || config, + client: this.client || (() => client(config)) }; - const {force} = await prompt.get(schema); - if (!force) { - console.log(chalk.yellow('[skip]:') + ` delete skipped for (${path})`); - yargs.exit(0); + // If not using force flag and not in interactive mode, ask for confirmation + if (!args.force && process.argv.slice(2).length) { + const shouldDelete = await confirm({ + message: 'This will delete all revisions of this asset from QuantCDN. Are you sure?', + initialValue: false, + active: 'Yes', + inactive: 'No' + }); + if (isCancel(shouldDelete) || !shouldDelete) { + throw new Error('Operation cancelled'); + } } - } - const quant = client(config); + if (!await context.config.fromArgs(args)) { + process.exit(1); + } - try { - await quant.delete(path); - } catch (err) { - console.log(chalk.red('[error]:') + ` Cannot delete path (${path})\n` + err.message); - yargs.exit(1); - } + const quant = context.client(context.config); + + try { + const response = await quant.delete(args.path); + + // Check if the response indicates success + if (response && !response.error && response.meta && response.meta[0]) { + const meta = response.meta[0]; + if (meta.deleted) { + return color.green(`Successfully removed [${args.path}]`); + } + if (meta.deleted_timestamp) { + return color.dim(`Path [${args.path}] was already deleted`); + } + } - console.log(chalk.green(`Successfully removed [${path}]`)); + throw new Error(`Unexpected response format: ${JSON.stringify(response, null, 2)}`); + } catch (err) { + // If we have a response in the error message, try to parse it + try { + const match = err.message.match(/Response: (.*)/s); + if (match) { + const responseData = JSON.parse(match[1]); + + // Check if this was actually a successful deletion + if (!responseData.error && responseData.meta && responseData.meta[0]) { + const meta = responseData.meta[0]; + if (meta.deleted) { + return color.green(`Successfully removed [${args.path}]`); + } + if (meta.deleted_timestamp) { + return color.dim(`Path [${args.path}] was already deleted`); + } + } + } + } catch (parseError) { + // If we can't parse the response, continue with original error + } + + // For actual errors + throw new Error(`Cannot delete path (${args.path || 'undefined'}): ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/deploy.js b/src/commands/deploy.js index 390bf21..242786b 100644 --- a/src/commands/deploy.js +++ b/src/commands/deploy.js @@ -1,212 +1,300 @@ -/** - * Deploy the configured build directory to QuantCDN. - * - * @usage - * quant deploy - */ -const chalk = require('chalk'); +const { text, confirm, isCancel } = require('@clack/prompts'); +const color = require('picocolors'); const config = require('../config'); const client = require('../quant-client'); const getFiles = require('../helper/getFiles'); -const normalizePaths = require('../helper/normalizePaths'); const path = require('path'); -const yargs = require('yargs'); const md5File = require('md5-file'); -const {chunk} = require('../helper/array'); -const quantUrl = require('../helper/quant-url'); +const { chunk } = require('../helper/array'); const revisions = require('../helper/revisions'); -const {sep} = require('path'); - -const command = {}; - -command.command = 'deploy [dir]'; -command.describe = 'Deploy the output of a static generator'; -command.builder = (yargs) => { - yargs.positional('dir', { - describe: 'Optional location of build artefacts ', - type: 'string', - default: null, - }); - yargs.options('attachments', { - describe: 'Find attachments', - alias: 'a', - type: 'boolean', - default: false, - }); - yargs.options('skip-unpublish', { - describe: 'Skip the automatic unpublish process', - alias: 'u', - type: 'boolean', - default: false, - }); - yargs.options('skip-unpublish-regex', { - describe: 'Skip the unpublish process for specific regex', - type: 'string', - }); - yargs.options('skip-purge', { - describe: 'Skip the automatic cache purge process', - alias: 'sp', - type: 'boolean', - default: false, - }); - yargs.option('chunk-size', { - describe: 'Control the chunk-size for concurrency', - alias: 'cs', - type: 'integer', - default: 10, - }); - yargs.option('force', { - describe: 'Force the deployment (ignore md5 match)', - alias: 'f', - type: 'boolean', - default: false, - }); - yargs.option('enable-index-html', { - describe: 'Push index.html files with page assets', - alias: 'h', - type: 'boolean', - default: false, - }); - yargs.option('revision-log', { - describe: 'Directory for the revision log file', - alias: 'r', - type: 'string', - }); -}; +const isMD5Match = require('../helper/is-md5-match'); -command.handler = async function(argv) { - let files; +const command = { + command: 'deploy [dir]', + describe: 'Deploy the output of a static generator', + + builder: (yargs) => { + return yargs + .positional('dir', { + describe: 'Directory containing static assets', + type: 'string' + }) + .option('attachments', { + describe: 'Deploy attachments', + type: 'boolean', + default: false, + hidden: true + }) + .option('skip-unpublish', { + describe: 'Skip the unpublish process', + type: 'boolean', + default: false + }) + .option('skip-unpublish-regex', { + describe: 'Skip the unpublish process for specific regex', + type: 'string' + }) + .option('skip-purge', { + describe: 'Skip the automatic cache purge process', + type: 'boolean', + default: false + }) + .option('enable-index-html', { + describe: 'Keep index.html in URLs', + type: 'boolean', + default: false + }) + .option('chunk-size', { + describe: 'Number of files to process at once', + type: 'number', + default: 10 + }) + .option('force', { + describe: 'Force deployment even if files exist', + type: 'boolean', + default: false + }) + .option('revision-log', { + describe: 'Path to revision log file', + type: 'string', + hidden: true + }); + }, - console.log(chalk.bold.green('*** Quant deploy ***')); + async promptArgs(providedArgs = {}) { + const configDir = config.get('dir') || 'build'; + + let dir = providedArgs.dir; + if (!dir) { + dir = await text({ + message: 'Enter the build directory to deploy', + defaultValue: configDir, + placeholder: configDir + }); + if (isCancel(dir)) return null; + } - // Make sure configuration is loaded. - if (!config.fromArgs(argv)) { - console.log(chalk.yellow('Quant is not configured, run init.')); - yargs.exit(1); - } + const attachments = providedArgs.attachments || false; - const dir = argv.dir || config.get('dir'); - const p = path.resolve(process.cwd(), dir); - const quant = client(config); + const enableIndexHtml = providedArgs['enable-index-html'] || false; - // Prepare local revision support. - revisions.enabled(argv.r !== undefined); - revisions.load(argv.r + sep + config.get('project')); + const chunkSize = providedArgs['chunk-size'] || 10; - try { - await quant.ping(); - } catch (err) { - console.log('Unable to connect to Quant\n' + chalk.red(err.message)); - yargs.exit(1); - } + let force = providedArgs.force; + if (typeof force !== 'boolean') { + force = await confirm({ + message: 'Force deployment? (ignore MD5 checks and revision log)', + initialValue: false, + active: 'Yes', + inactive: 'No' + }); + if (isCancel(force)) return null; + } - try { - files = await getFiles(p); - } catch (err) { - console.log(chalk.red(err.message)); - yargs.exit(1); - } + const skipUnpublish = await confirm({ + message: 'Skip the automatic unpublish process?', + initialValue: false, + active: 'Yes', + inactive: 'No' + }); + if (isCancel(skipUnpublish)) return null; - // Chunk the files array into smaller pieces to handle - // concurrency with the api requests. - if (argv['chunk-size'] > 20) { - argv['chunk-size'] = 20; - } - files = chunk(files, argv['chunk-size']); - for (let i = 0; i < files.length; i++) { - await Promise.all(files[i].map(async (file) => { - const md5 = md5File.sync(file); - let filepath = path.relative(p, file); - let revision = false; - filepath = normalizePaths(filepath); - - if (revisions.enabled()) { - if (revisions.has(filepath, md5)) { - console.log(chalk.blue(`Published version is up-to-date (${filepath})`)); + const skipPurge = await confirm({ + message: 'Skip the automatic cache purge process?', + initialValue: false, + active: 'Yes', + inactive: 'No' + }); + if (isCancel(skipPurge)) return null; + + return { + dir, + attachments, + 'skip-unpublish': skipUnpublish, + 'skip-purge': skipPurge, + 'enable-index-html': enableIndexHtml, + 'chunk-size': chunkSize, + force + }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = this.client ? this.client(config) : client(config); + + try { + await quant.ping(); + } catch (err) { + throw new Error(`Unable to connect to Quant: ${err.message}`); + } + + const buildDir = args.dir || config.get('dir') || 'build'; + const p = path.resolve(process.cwd(), buildDir); + console.log('Deploying from:', p); + + if (config.get('enableIndexHtml') === undefined) { + config.set({ + enableIndexHtml: args['enable-index-html'] || false + }); + config.save(); + console.log(color.dim( + `Project configured with ${args['enable-index-html'] ? '--enable-index-html' : 'no --enable-index-html'}` + )); + } + + const projectName = config.get('project'); + const revisionLogPath = args['revision-log'] || path.resolve(process.cwd(), `quant-revision-log_${projectName}`); + revisions.enabled(true); + revisions.load(revisionLogPath); + console.log(color.dim(`Using revision log: ${revisionLogPath}`)); + + let files; + try { + files = await getFiles(p); + console.log('Found', files.length, 'files to process'); + } catch (err) { + throw new Error(err.message); + } + + files = chunk(files, args['chunk-size'] || 10); + for (let i = 0; i < files.length; i++) { + await Promise.all(files[i].map(async (file) => { + const filepath = path.relative(p, file); + const md5 = md5File.sync(file); + + if (!args.force && revisions.has(filepath, md5)) { + console.log(color.dim(`Skipping ${filepath} (content unchanged)`)); return; } - } else { + try { - revision = await quant.revisions(filepath); - } catch (err) {} - if (revision && revision.md5 == md5) { - console.log(chalk.blue(`Published version is up-to-date (${filepath})`)); - return; - } - } + const meta = await quant.send( + file, + filepath, + true, + args.attachments, + args['skip-purge'], + args['enable-index-html'] + ); - try { - const meta = await quant.send(file, filepath, true, argv.attachments, argv['skip-purge'], argv['enable-index-html']); - revisions.store(meta); - } catch (err) { - revisions.store({url: filepath, md5}); - console.log(chalk.yellow(err.message + ` (${filepath})`)); - return; - } - console.log(chalk.bold.green('✅') + ` ${filepath}`); - })); - } + console.log(color.green('✓'), filepath); + return meta; + } catch (err) { + if (!args.force && isMD5Match(err)) { + if (revisions.enabled()) { + revisions.store({ + url: filepath, + md5: md5 + }); + } + console.log(color.dim(`Skipping ${filepath} (content unchanged)`)); + return; + } - revisions.save(); + if (args.force && isMD5Match(err)) { + console.log(color.yellow(`Force uploading ${filepath} (ignoring MD5 match)`)); + return; + } - if (argv['skip-unpublish']) { - console.log(chalk.yellow(' -> Skipping the automatic unpublish process')); - yargs.exit(0); - } + console.log(color.yellow(`Warning: Failed to deploy ${filepath}: ${err.message}`)); + return; + } + })); + } - try { - data = await quant.meta(true); - } catch (err) { - console.log(chalk.yellow(err.message)); - } + revisions.save(); - // Quant meta returns relative paths, so we map our local filesystem - // to relative URL paths so that we can do a simple [].includes to - // determine if we need to unpublish the URL. - const relativeFiles = []; - for (let i = 0; i < files.length; i++) { - // Quant URLs are all lowercase, relative paths need to be made lc for comparison. - await Promise.all(files[i].map((item) => { - relativeFile = argv['enable-index-html'] ? `/${path.relative(p, item).toLowerCase()}` : quantUrl.prepare(path.relative(p, item)); - relativeFiles.push(relativeFile); - })); - } + if (args['skip-unpublish']) { + console.log(color.dim('Skipping unpublish process')); + return 'Deployment completed successfully'; + } - if (!data || ! 'records' in data) { - // The API doesn't return meta data if nothing has previously been - // pushed for the project. - return; - } + let data; + try { + data = await quant.meta(true); + } catch (err) { + return 'Deployment completed with warnings'; + } + + const normalizePath = (filePath) => { + filePath = filePath.startsWith('/') ? filePath.slice(1) : filePath; + filePath = filePath.toLowerCase(); + if (filePath.endsWith('/index.html')) { + return filePath.slice(0, -11); + } + if (filePath === 'index.html') { + return ''; + } + if (filePath.endsWith('index.html')) { + return filePath.slice(0, -10); + } + return filePath; + }; - data.records.map(async (item) => { - const f = quantUrl.prepare(item.url); + const relativeFiles = new Set(); + for (let i = 0; i < files.length; i++) { + files[i].forEach((file) => { + const relativePath = path.relative(p, file); + const normalizedPath = normalizePath(relativePath); + relativeFiles.add(normalizedPath); - if (relativeFiles.includes(item.url) || relativeFiles.includes(f)) { - return; + if (normalizedPath.endsWith('/')) { + relativeFiles.add(normalizedPath.slice(0, -1)); + } else { + relativeFiles.add(normalizedPath + '/'); + } + }); } - if (item.type && item.type == 'redirect') { - // @todo: support redirects with deploy. - return; + if (!data || !('records' in data)) { + return 'Deployment completed successfully'; } - try { - // Skip unpublish process if skip unpublish regex matches. - if (argv['skip-unpublish-regex']) { - const match = item.url.match(argv['skip-unpublish-regex']); + const filesToUnpublish = []; + for (const item of data.records) { + const remoteUrl = normalizePath(item.url); + + if (relativeFiles.has(remoteUrl) || + relativeFiles.has(remoteUrl + '/') || + relativeFiles.has(remoteUrl.replace(/\/$/, ''))) { + continue; + } + + // Skip redirects and functions + if (item.type && (item.type === 'redirect' || item.type === 'edge_function' || item.type === 'edge_auth' || item.type === 'edge_filter')) { + continue; + } + + if (args['skip-unpublish-regex']) { + const match = item.url.match(args['skip-unpublish-regex']); if (match) { - console.log(chalk.blue(`Skipping unpublish via regex match: (${item.url})`)); - return; + continue; } } - await quant.unpublish(item.url); - } catch (err) { - return console.log(chalk.yellow(err.message + ` (${item.url})`)); + + filesToUnpublish.push(item.url); } - console.log(chalk.bold.green('✅') + ` ${item.url} unpublished.`); - }); - + const unpublishBatches = chunk(filesToUnpublish, args['chunk-size'] || 10); + for (const batch of unpublishBatches) { + await Promise.all(batch.map(async (url) => { + try { + await quant.unpublish(url); + console.log(color.yellow(`✓ ${url} unpublished`)); + } catch (err) { + console.log(color.red(`Failed to unpublish ${url}: ${err.message}`)); + } + })); + } + return 'Deployment completed successfully'; + } }; module.exports = command; diff --git a/src/commands/file.js b/src/commands/file.js index 5964ff6..7db937f 100644 --- a/src/commands/file.js +++ b/src/commands/file.js @@ -1,49 +1,86 @@ /** * Deploy a single file to QuantCDN. - * - * This allows an optional paramter to define where the asset - * will be accessible by QuantCDN. - * - * @usage - * quant file */ +const { text, isCancel } = require('@clack/prompts'); const config = require('../config'); const client = require('../quant-client'); -const chalk = require('chalk'); -const util = require('util'); - -const command = {}; - -command.command = 'file '; -command.describe = 'Deploy a single asset'; -command.builder = (yargs) => { - yargs.positional('file', { - describe: 'Path to local file', - type: 'string', - }); - yargs.positional('location', { - describe: 'The access URI', - type: 'string', - }); -}; - -command.handler = function(argv) { - const filepath = argv.file; - const location = argv.location; +const fs = require('fs'); +const isMD5Match = require('../helper/is-md5-match'); - // @TODO: Support dir. - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } +const command = { + command: 'file ', + describe: 'Deploy a single asset', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to local file', + type: 'string', + demandOption: true + }) + .positional('location', { + describe: 'The access URI', + type: 'string', + demandOption: true + }) + .example('quant file style.css /css/style.css', 'Deploy a CSS file') + .example('quant file image.jpg /images/header.jpg', 'Deploy an image'); + }, - console.log(chalk.bold.green('*** Quant file ***')); + async promptArgs(providedArgs = {}) { + let file = providedArgs.file; + if (!file) { + file = await text({ + message: 'Enter path to local file', + validate: value => !value ? 'File path is required' : undefined + }); + if (isCancel(file)) return null; + } - client(config).file(filepath, location) - .then((body) => console.log(chalk.green('Success: ') + ` Added [${filepath}]`)) - .catch((err) => { - msg = util.format(chalk.yellow('File [%s] exists at location (%s)'), filepath, location); - console.log(msg); + let location = providedArgs.location; + if (!location) { + location = await text({ + message: 'Enter the access URI (where the file will be available)', + validate: value => !value ? 'Location is required' : undefined }); + if (isCancel(location)) return null; + } + + return { file, location }; + }, + + async handler(args) { + const context = { + config: this.config || config, + client: this.client || (() => client(config)), + fs: this.fs || fs + }; + + if (!args || (!args.file && !args.location)) { + const promptedArgs = await this.promptArgs(); + if (!promptedArgs) { + throw new Error('Operation cancelled'); + } + args = { ...args || {}, ...promptedArgs }; + } + + if (!await context.config.fromArgs(args)) { + process.exit(1); + } + + const quant = context.client(context.config); + + try { + await quant.file(args.file, args.location); + return `Added [${args.file}]`; + } catch (err) { + if (isMD5Match(err)) { + return `Skipped [${args.file}] (content unchanged)`; + } + + throw new Error(`File [${args.file}] exists at location (${args.location})`); + } + } }; module.exports = command; diff --git a/src/commands/function.js b/src/commands/function.js new file mode 100644 index 0000000..f3bab99 --- /dev/null +++ b/src/commands/function.js @@ -0,0 +1,95 @@ +/** + * Deploy an edge function. + * + * @usage + * quant function [uuid] + */ +const { text, isCancel } = require('@clack/prompts'); +const config = require('../config'); +const client = require('../quant-client'); +const { validateUUID } = require('../helper/validate-uuid'); + +const command = { + command: 'function [uuid]', + describe: 'Deploy an edge function', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to function file', + type: 'string', + demandOption: true + }) + .positional('description', { + describe: 'Description of the function', + type: 'string', + demandOption: true + }) + .positional('uuid', { + describe: 'UUID of existing function to update', + type: 'string', + coerce: value => { + if (value && !validateUUID(value)) { + throw new Error('Invalid UUID format'); + } + return value; + } + }) + .example('quant function handler.js "My edge function"', 'Deploy a new function') + .example('quant function handler.js "Updated function" 019361ae-2516-788a-8f50-e803ff561c34', 'Update existing function'); + }, + + async promptArgs(providedArgs = {}) { + let file = providedArgs.file; + if (!file) { + file = await text({ + message: 'Enter path to function file', + validate: value => !value ? 'File path is required' : undefined + }); + if (isCancel(file)) return null; + } + + let description = providedArgs.description; + if (!description) { + description = await text({ + message: 'Enter function description', + validate: value => !value ? 'Description is required' : undefined + }); + if (isCancel(description)) return null; + } + + let uuid = providedArgs.uuid; + if (uuid === undefined) { + uuid = await text({ + message: 'Enter UUID to update (optional)', + }); + if (isCancel(uuid)) return null; + } + + return { file, description, uuid: uuid || null }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); + + try { + const response = await quant.edgeFunction(args.file, args.description, args.uuid); + if (args.uuid) { + return `Updated edge function [${args.uuid}]`; + } + return `Created edge function [${response.uuid}]`; + } catch (err) { + throw new Error(`Failed to deploy edge function: ${err.message}`); + } + } +}; + +module.exports = command; \ No newline at end of file diff --git a/src/commands/function_auth.js b/src/commands/function_auth.js new file mode 100644 index 0000000..57d73b1 --- /dev/null +++ b/src/commands/function_auth.js @@ -0,0 +1,100 @@ +/** + * Deploy an edge auth function. + * + * @usage + * quant auth [uuid] + */ +const { text, isCancel } = require('@clack/prompts'); +const config = require('../config'); +const client = require('../quant-client'); +const { validateUUID } = require('../helper/validate-uuid'); + +const command = { + command: 'auth [uuid]', + describe: 'Deploy an edge auth function', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to auth function file', + type: 'string', + demandOption: true + }) + .positional('description', { + describe: 'Description of the auth function', + type: 'string', + demandOption: true + }) + .positional('uuid', { + describe: 'UUID of existing auth function to update', + type: 'string', + coerce: value => { + if (value && !validateUUID(value)) { + throw new Error('Invalid UUID format'); + } + return value; + } + }) + .example('quant auth auth.js "My auth function"', 'Deploy a new auth function') + .example('quant auth auth.js "Updated auth" 019361ae-2516-788a-8f50-e803ff561c34', 'Update existing auth function'); + }, + + async promptArgs(providedArgs = {}) { + let file = providedArgs.file; + if (!file) { + file = await text({ + message: 'Enter path to auth function file', + validate: value => !value ? 'File path is required' : undefined + }); + if (isCancel(file)) return null; + } + + let description = providedArgs.description; + if (!description) { + description = await text({ + message: 'Enter auth function description', + validate: value => !value ? 'Description is required' : undefined + }); + if (isCancel(description)) return null; + } + + let uuid = providedArgs.uuid; + if (uuid === undefined) { + uuid = await text({ + message: 'Enter UUID to update (optional)', + validate: value => { + if (!value) return undefined; // Allow empty for new functions + if (!validateUUID(value)) return 'Invalid UUID format'; + return undefined; + } + }); + if (isCancel(uuid)) return null; + } + + return { file, description, uuid: uuid || null }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); + + try { + const response = await quant.edgeAuth(args.file, args.description, args.uuid); + if (args.uuid) { + return `Updated edge auth function [${args.uuid}]`; + } + return `Created edge auth function [${response.uuid}]`; + } catch (err) { + throw new Error(`Failed to deploy edge auth function: ${err.message}`); + } + } +}; + +module.exports = command; \ No newline at end of file diff --git a/src/commands/function_filter.js b/src/commands/function_filter.js new file mode 100644 index 0000000..042e8f8 --- /dev/null +++ b/src/commands/function_filter.js @@ -0,0 +1,100 @@ +/** + * Deploy an edge filter function. + * + * @usage + * quant filter [uuid] + */ +const { text, isCancel } = require('@clack/prompts'); +const config = require('../config'); +const client = require('../quant-client'); +const { validateUUID } = require('../helper/validate-uuid'); + +const command = { + command: 'filter [uuid]', + describe: 'Deploy an edge filter function', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to filter function file', + type: 'string', + demandOption: true + }) + .positional('description', { + describe: 'Description of the filter', + type: 'string', + demandOption: true + }) + .positional('uuid', { + describe: 'UUID of existing filter to update', + type: 'string', + coerce: value => { + if (value && !validateUUID(value)) { + throw new Error('Invalid UUID format'); + } + return value; + } + }) + .example('quant filter filter.js "My edge filter"', 'Deploy a new filter') + .example('quant filter filter.js "Updated filter" 019361ae-2516-788a-8f50-e803ff561c34', 'Update existing filter'); + }, + + async promptArgs(providedArgs = {}) { + let file = providedArgs.file; + if (!file) { + file = await text({ + message: 'Enter path to filter function file', + validate: value => !value ? 'File path is required' : undefined + }); + if (isCancel(file)) return null; + } + + let description = providedArgs.description; + if (!description) { + description = await text({ + message: 'Enter filter description', + validate: value => !value ? 'Description is required' : undefined + }); + if (isCancel(description)) return null; + } + + let uuid = providedArgs.uuid; + if (uuid === undefined) { + uuid = await text({ + message: 'Enter UUID to update (optional)', + validate: value => { + if (!value) return undefined; // Allow empty for new functions + if (!validateUUID(value)) return 'Invalid UUID format'; + return undefined; + } + }); + if (isCancel(uuid)) return null; + } + + return { file, description, uuid: uuid || null }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); + + try { + const response = await quant.edgeFilter(args.file, args.description, args.uuid); + if (args.uuid) { + return `Updated edge filter [${args.uuid}]`; + } + return `Created edge filter [${response.uuid}]`; + } catch (err) { + throw new Error(`Failed to deploy edge filter: ${err.message}`); + } + } +}; + +module.exports = command; \ No newline at end of file diff --git a/src/commands/functions.js b/src/commands/functions.js new file mode 100644 index 0000000..02485ba --- /dev/null +++ b/src/commands/functions.js @@ -0,0 +1,120 @@ +/** + * Deploy edge functions from a JSON file. + * + * @usage + * quant functions + */ +const fs = require('fs'); +const config = require('../config'); +const client = require('../quant-client'); +const color = require('picocolors'); +const isMD5Match = require('../helper/is-md5-match'); + +const command = { + command: 'functions ', + describe: 'Deploy edge functions from a JSON configuration file', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to JSON configuration file', + type: 'string', + demandOption: true + }) + .example('quant functions functions.json', 'Deploy functions from config file'); + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + const context = { + config: this.config || config, + client: this.client || (() => client(config)) + }; + + if (!await context.config.fromArgs(args)) { + process.exit(1); + } + + const quant = context.client(context.config); + + // Read and parse the JSON file + let functions; + try { + const content = fs.readFileSync(args.file, 'utf8'); + functions = JSON.parse(content); + } catch (err) { + throw new Error(`Failed to read functions file: ${err.message}`); + } + + // Process each function + for (const func of functions) { + const { type, path, description, uuid } = func; + + // Validate required fields + if (!type || !path || !description) { + throw new Error('Each function must have type, path, and description'); + } + + // Validate file exists + if (!fs.existsSync(path)) { + throw new Error(`Function file not found: ${path}`); + } + + try { + switch (type.toLowerCase()) { + case 'auth': + try { + await quant.edgeAuth(path, description, uuid); + console.log(color.green(`Deployed auth function: ${path}`)); + } catch (err) { + if (isMD5Match(err)) { + console.log(color.dim(`Skipped auth function: ${path} (content unchanged)`)); + } else { + throw err; + } + } + break; + + case 'filter': + try { + await quant.edgeFilter(path, description, uuid); + console.log(color.green(`Deployed filter function: ${path}`)); + } catch (err) { + if (isMD5Match(err)) { + console.log(color.dim(`Skipped filter function: ${path} (content unchanged)`)); + } else { + throw err; + } + } + break; + + case 'edge': + case 'function': + try { + await quant.edgeFunction(path, description, uuid); + console.log(color.green(`Deployed edge function: ${path}`)); + } catch (err) { + if (isMD5Match(err)) { + console.log(color.dim(`Skipped edge function: ${path} (content unchanged)`)); + } else { + throw err; + } + } + break; + + default: + throw new Error(`Invalid function type: ${type}`); + } + } catch (err) { + throw new Error(`Failed to deploy ${type} function ${path}: ${err.message}`); + } + } + + return color.green('All functions processed successfully'); + } +}; + +module.exports = command; \ No newline at end of file diff --git a/src/commands/info.js b/src/commands/info.js index 0df306e..a9dcf8a 100644 --- a/src/commands/info.js +++ b/src/commands/info.js @@ -4,61 +4,52 @@ * @usage * quant info */ -const chalk = require('chalk'); -const client = require('../quant-client'); const config = require('../config'); +const client = require('../quant-client'); -const command = {}; - -command.command = 'info'; -command.describe = 'Give info based on current configuration'; -command.builder = {}; - -command.handler = function(argv) { - console.log(chalk.bold.green('*** Quant info ***')); - - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); +const command = { + command: 'info', + describe: 'Show information about current configuration', + + builder: (yargs) => { + return yargs; + }, + + async promptArgs() { + // No arguments needed for info command + return {}; + }, + + async handler(args) { + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); + + try { + await quant.ping(); + } catch (err) { + throw new Error(`Unable to connect to quant: ${err.message}`); + } + + let output = ''; + output += `Endpoint: ${config.get('endpoint')}\n`; + output += `Customer: ${config.get('clientid')}\n`; + output += `Project: ${config.get('project')}\n`; + output += `Token: ****\n`; + + try { + const meta = await quant.meta(); + if (meta && meta.total_records) { + output += `\nTotal records: ${meta.total_records}`; + } + } catch (err) { + output += '\nCould not fetch metadata'; + } + + return output; } - - console.log(`Endpoint: ${config.get('endpoint')}`); - console.log(`Customer: ${config.get('clientid')}`); - console.log(`Project: ${config.get('project')}`); - console.log(`Token: ****`); - - const quant = client(config); - - quant.ping() - .then(async (data) => { - console.log(chalk.bold.green(`✅✅✅ Successfully connected to ${config.get('project')}`)); - - quant.meta() - .then((data) => { - console.log(chalk.yellow('\nInfo:')); - if (data && data.total_records) { - console.log(`Total records: ${data.total_records}`); - const totals = {'content': 0, 'redirects': 0}; - - if (data.records) { - data.records.map(async (item) => { - if (item.type && item.type == 'redirect') { - totals.redirects++; - } else { - totals.content++; - } - }); - console.log(` - content: ${totals.content}`); - console.log(` - redirects: ${totals.redirects}`); - } - } else { - console.log('Use deploy to start seeding!'); - } - }) - .catch((err) => { - console.error(chalk.red(err.message)); - }); - }) - .catch((err) => console.log(chalk.bold.red(`Unable to connect to quant ${err.message}`))); }; module.exports = command; diff --git a/src/commands/init.js b/src/commands/init.js index fcd73b5..f9c6477 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -1,98 +1,133 @@ -/** - * Prepare the QuantCDN configuration file. - * - * This uses a wizard or allows you to pass in with options. - * - * @TODO: allow partial options/wizard. - * - * @usage - * quant init - * quant init -t -c -e -d - */ -const chalk = require('chalk'); -const prompt = require('prompt'); - +const { text, password, isCancel } = require('@clack/prompts'); const config = require('../config'); const client = require('../quant-client'); -const command = {}; +const command = { + command: 'init', + describe: 'Initialize a project in the current directory', + + builder: (yargs) => { + return yargs + .option('dir', { + alias: 'd', + describe: 'Directory containing static assets', + type: 'string', + default: 'build' + }) + .option('clientid', { + alias: 'c', + describe: 'Project customer id for QuantCDN', + type: 'string' + }) + .option('project', { + alias: 'p', + describe: 'Project name for QuantCDN', + type: 'string' + }) + .option('token', { + alias: 't', + describe: 'Project token for QuantCDN', + type: 'string' + }) + .option('endpoint', { + alias: 'e', + describe: 'QuantCDN API endpoint', + type: 'string', + default: 'https://api.quantcdn.io/v1' + }); + }, -command.command = 'init'; -command.describe = 'Initialise a project in the current directory'; + async promptArgs() { + const clientid = await text({ + message: 'Enter QuantCDN client id', + validate: value => { + if (!value) return 'Client ID is required'; + if (!/^[a-zA-Z0-9\-\_]+$/.test(value)) { + return 'Client ID must contain only letters, numbers, underscores or dashes'; + } + } + }); -command.builder = (yargs) => { - yargs.option('dir', { - alias: 'd', - describe: 'Built asset directory', - type: 'string', - default: 'build', - }); + if (isCancel(clientid)) return null; - return yargs; -}; + const project = await text({ + message: 'Enter QuantCDN project', + validate: value => { + if (!value) return 'Project is required'; + if (!/^[a-zA-Z0-9\-]+$/.test(value)) { + return 'Project must contain only letters, numbers or dashes'; + } + } + }); -command.handler = function(argv) { - const token = Array.isArray(argv.token) ? argv.token.pop() : argv.token; - const clientid = Array.isArray(argv.clientid) ? argv.clientid.pop() : argv.clientid; - const endpoint = Array.isArray(argv.endpoint) ? argv.endpoint.pop() : argv.endpoint; - const project = Array.isArray(argv.project) ? argv.project.pop() : argv.project; - const dir = Array.isArray(argv.dir) ? argv.dir.pop() : argv.dir; - - console.log(chalk.bold.green('*** Initialise Quant ***')); - - if (!token || !clientid || !project) { - const schema = { - properties: { - endpoint: { - required: true, - description: 'Enter QuantCDN endpoint', - default: 'https://api.quantcdn.io', - }, - clientid: { - pattern: /^[a-zA-Z0-9\-\_]+$/, - message: 'Client id must be only letters, numbers, underscores or dashes', - required: true, - description: 'Enter QuantCDN client id', - }, - project: { - pattern: /^[a-zA-Z0-9\-]+$/, - message: 'Project must be only letters, numbers or dashes', - required: true, - description: 'Enter QuantCDN project', - }, - token: { - hidden: true, - replace: '*', - required: true, - description: 'Enter QuantCDN project token', - }, - bearer: { - hidden: true, - replace: '*', - required: false, - description: 'Enter an optional QuantCDN API token', - }, - dir: { - required: true, - description: 'Directory containing static assets', - default: 'build', - }, - }, - }; - prompt.start(); - prompt.get(schema, function(err, result) { - config.set(result); - config.save(); - client(config).ping(config) - .then((message) => console.log(chalk.bold.green(`✅✅✅ Successfully connected to ${message.project}`))) - .catch((message) => console.log(chalk.bold.red(`Unable to connect to quant ${message.project}`))); + if (isCancel(project)) return null; + + const token = await password({ + message: 'Enter QuantCDN project token', + validate: value => !value ? 'Token is required' : undefined }); - } else { - config.set({clientid, project, token, endpoint, dir}); + + if (isCancel(token)) return null; + + const dir = await text({ + message: 'Directory containing static assets', + defaultValue: 'build' + }); + + if (isCancel(dir)) return null; + + return { + endpoint: 'https://api.quantcdn.io/v1', + clientid, + project, + token, + dir + }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + // If any required fields are missing, go into interactive mode + if (!args.clientid || !args.project || !args.token) { + const promptedArgs = await this.promptArgs(); + if (!promptedArgs) { + throw new Error('Operation cancelled'); + } + args = { ...args, ...promptedArgs }; + } + + const config_args = { + endpoint: args.endpoint || 'https://api.quantcdn.io/v1', + clientid: args.clientid, + project: args.project, + token: args.token, + dir: args.dir || 'build' + }; + + // Validate we have all required fields before saving + if (!config_args.clientid || !config_args.project || !config_args.token) { + throw new Error('Missing required configuration fields'); + } + + config.set(config_args); config.save(); - client(config).ping(config) - .then((message) => console.log(chalk.bold.green(`✅✅✅ Successfully connected to ${message.project}`))) - .catch((message) => console.log(chalk.bold.red(`Unable to connect to quant ${message.project}`))); + + const _client = client(config); + try { + const message = await _client.ping(config); + return `Successfully connected to ${message.project}`; + } catch (error) { + // If save failed, clean up the incomplete config file + try { + fs.unlinkSync('quant.json'); + } catch (e) { + // Ignore error if file doesn't exist + } + throw new Error(`Unable to connect to quant: ${error.message}`); + } } }; diff --git a/src/commands/page.js b/src/commands/page.js index 03375c8..5c2a45a 100644 --- a/src/commands/page.js +++ b/src/commands/page.js @@ -1,45 +1,99 @@ /** * Deploy a single index.html file to QuantCDN. - * - * This allows an optional paramter to define where the asset - * will be accessible by QuantCDN. - * - * @usage - * quant page */ +const { text, isCancel } = require('@clack/prompts'); const config = require('../config'); const client = require('../quant-client'); -const chalk = require('chalk'); - -const command = {}; - -command.command = 'page '; -command.describe = 'Make a local page asset available via Quant'; -command.builder = (yargs) => { - yargs.positional('file', { - describe: 'Path to local file', - type: 'string', - }); - yargs.positional('location', { - describe: 'The access URI', - type: 'string', - }); -}; +const fs = require('fs'); +const isMD5Match = require('../helper/is-md5-match'); -command.handler = function(argv) { - const filepath = argv.file; - const location = argv.location; +const command = { + command: 'page ', + describe: 'Make a local page asset available via Quant', + + builder: (yargs) => { + return yargs + .positional('file', { + describe: 'Path to local HTML file', + type: 'string', + demandOption: true + }) + .positional('location', { + describe: 'The access URI', + type: 'string', + demandOption: true + }) + .option('enable-index-html', { + describe: 'Keep index.html in URLs', + type: 'boolean' + }) + .example('quant page index.html /about', 'Deploy a page') + .example('quant page about.html /about --enable-index-html', 'Deploy with index.html suffix'); + }, - console.log(chalk.bold.green('*** Quant page ***')); + async promptArgs(providedArgs = {}) { + let file = providedArgs.file; + if (!file) { + file = await text({ + message: 'Enter path to local HTML file', + validate: value => !value ? 'File path is required' : undefined + }); + if (isCancel(file)) return null; + } - // @TODO: add dir support. - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + let location = providedArgs.location; + if (!location) { + location = await text({ + message: 'Enter the access URI (where the page will be available)', + validate: value => !value ? 'Location is required' : undefined + }); + if (isCancel(location)) return null; + } + + return { file, location }; + }, + + async handler(args) { + const context = { + config: this.config || config, + client: this.client || (() => client(config)), + fs: this.fs || fs + }; + + if (!args || (!args.file && !args.location)) { + const promptedArgs = await this.promptArgs(); + if (!promptedArgs) { + throw new Error('Operation cancelled'); + } + args = { ...args || {}, ...promptedArgs }; + } - client(config).markup(filepath, location) - .then((body) => console.log(chalk.green('Success: ') + ` Added [${filepath}]`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); + if (!await context.config.fromArgs(args)) { + process.exit(1); + } + + const enableIndexHtml = context.config.get('enableIndexHtml'); + let location = args.location; + + if (enableIndexHtml && !location.endsWith('index.html')) { + location = location.replace(/\/?$/, '/index.html'); + } + else if (!enableIndexHtml && location.endsWith('index.html')) { + location = location.replace(/index\.html$/, ''); + } + + const quant = context.client(context.config); + + try { + await quant.markup(args.file, location); + return `Added [${args.file}]`; + } catch (err) { + if (isMD5Match(err)) { + return `Skipped [${args.file}] (content unchanged)`; + } + throw new Error(`Failed to add page: ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/proxy.js b/src/commands/proxy.js deleted file mode 100644 index c9735f7..0000000 --- a/src/commands/proxy.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Proxy a route in QuantCDN to an origin. - * - * @usage - * quant proxy --status --basicAuthUser --basicAuthPass - */ -const chalk = require('chalk'); -const config = require('../config'); -const client = require('../quant-client'); - -const command = {}; - -command.command = 'proxy [status] [basicAuthUser] [basicAuthPass]'; -command.describe = 'Create a proxy to allow traffic directly to origin'; -command.builder = (yargs) => { - yargs.positional('path', { - describe: 'The path that end users will see', - type: 'string', - }); - - yargs.positional('origin', { - describe: 'FQDN including path to proxy to', - type: 'string', - }); - - yargs.positional('status', { - describe: 'If the proxy is enabled', - type: 'boolean', - default: true, - }); - - yargs.positional('basicAuthUser', { - describe: 'User between edge and origin', - type: 'string', - default: null, - }); - - yargs.positional('basicAuthPass', { - describe: 'Password between edge and origin', - type: 'string', - default: null, - }); - - return yargs; -}; - -command.handler = function(argv) { - const url = argv.path; - const dest = argv.origin; - const status = argv.status; - const user = argv.basicAuthUser; - const pass = argv.basicAuthPass; - - // @TODO: Accept argv.dir. - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } - - console.log(chalk.bold.green('*** Quant proxy ***')); - - client(config).proxy(url, dest, status, user, pass) - .then((body) => console.log(chalk.green('Success: ') + ` Added proxy for ${url} to ${dest}`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); -}; - -module.exports = command; diff --git a/src/commands/publish.js b/src/commands/publish.js index 14f39a6..6acd048 100644 --- a/src/commands/publish.js +++ b/src/commands/publish.js @@ -4,47 +4,87 @@ * @usage * quant publish */ -const chalk = require('chalk'); -const client = require('../quant-client'); +const { text, isCancel } = require('@clack/prompts'); const config = require('../config'); +const client = require('../quant-client'); -const command = {}; - -command.command = 'publish '; -command.describe = 'Publish an asset'; -command.builder = (yargs) => { - yargs.options('revision', { - describe: 'The revision id to publish', - alias: 'r', - type: 'string', - default: 'latest', - }); -}; +const command = { + command: 'publish [path]', + describe: 'Publish an asset', + + builder: (yargs) => { + return yargs + .positional('path', { + describe: 'Path to publish', + type: 'string' + }) + .option('revision', { + alias: 'r', + describe: 'The revision id to publish', + type: 'string', + default: 'latest' + }); + }, -command.handler = function(argv) { - console.log(chalk.bold.green('*** Quant publish ***')); + async promptArgs(providedArgs = {}) { + let path = providedArgs.path; + if (!path) { + path = await text({ + message: 'Enter the path to publish', + validate: value => !value ? 'Path is required' : undefined + }); + if (isCancel(path)) return null; + } - // config.fromArgs(argv); - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + let revision = providedArgs.revision; + if (!revision) { + revision = await text({ + message: 'Enter revision ID (or press Enter for latest)', + defaultValue: 'latest' + }); + if (isCancel(revision)) return null; + } + + return { path, revision }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + if (!args.path) { + const promptedArgs = await this.promptArgs(); + if (!promptedArgs) { + throw new Error('Operation cancelled'); + } + args = { ...args, ...promptedArgs }; + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); - const _client = client(config); - - if (argv.revision == 'latest') { - _client.revisions(argv.path) - .then((res) => { - const revisionIds = Object.keys(res.revisions); - const latestRevision = Math.max(...revisionIds); - _client.publish(argv.path, latestRevision) - .then((res) => console.log(chalk.green('Success:') + ` Published successfully`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - }) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - } else { - _client.publish(argv.path, argv.revision) - .then((res) => console.log(chalk.green('Success:') + ` Published successfully`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); + if (args.revision === 'latest') { + try { + const res = await quant.revisions(args.path); + const revisionIds = Object.keys(res.revisions); + const latestRevision = Math.max(...revisionIds); + await quant.publish(args.path, latestRevision); + return 'Published successfully'; + } catch (err) { + throw new Error(`Failed to publish: ${err.message}`); + } + } else { + try { + await quant.publish(args.path, args.revision); + return 'Published successfully'; + } catch (err) { + throw new Error(`Failed to publish: ${err.message}`); + } + } } }; diff --git a/src/commands/purge.js b/src/commands/purge.js index 2dccd4c..4e56da0 100644 --- a/src/commands/purge.js +++ b/src/commands/purge.js @@ -1,31 +1,126 @@ /** - * Purge the cache for a given url. + * Purge the cache for a given URL. * * @usage - * quant unpublish + * quant purge */ -const chalk = require('chalk'); -const client = require('../quant-client'); +const { text, confirm, isCancel } = require('@clack/prompts'); const config = require('../config'); +const client = require('../quant-client'); -const command = {}; +const command = { + command: 'purge ', + describe: 'Purge the cache for a given URL', + + builder: (yargs) => { + return yargs + .positional('path', { + describe: 'Path to purge from cache', + type: 'string', + coerce: (arg) => { + return arg.replace(/^["']|["']$/g, ''); + } + }) + .option('cache-keys', { + describe: 'Cache keys to purge (space separated)', + type: 'string', + conflicts: 'path' + }) + .option('soft-purge', { + describe: 'Mark content as stale rather than delete from edge caches', + type: 'boolean', + default: false + }); + }, -command.command = 'purge '; -command.describe = 'Purge the cache for a given url'; -command.builder = {}; + async promptArgs(providedArgs = {}) { + let path = providedArgs.path; + let cacheKeys = providedArgs['cache-keys']; + let softPurge = providedArgs['soft-purge']; -command.handler = function(argv) { - console.log(chalk.bold.green('*** Quant purge ***')); + // If neither path nor cache-keys provided, prompt for one + if (!path && !cacheKeys) { + const purgeType = await select({ + message: 'What would you like to purge?', + options: [ + { value: 'path', label: 'Purge by path' }, + { value: 'keys', label: 'Purge by cache keys' } + ] + }); + if (isCancel(purgeType)) return null; - // config.fromArgs(argv); - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + if (purgeType === 'path') { + path = await text({ + message: 'Enter path to purge from cache', + validate: value => !value ? 'Path is required' : undefined + }); + if (isCancel(path)) return null; + } else { + cacheKeys = await text({ + message: 'Enter cache keys (space separated)', + validate: value => !value ? 'Cache keys are required' : undefined + }); + if (isCancel(cacheKeys)) return null; + } + } + + // If not provided and in interactive mode, ask about soft purge + if (softPurge === undefined) { + softPurge = await confirm({ + message: 'Use soft purge (mark as stale rather than delete)?', + initialValue: false + }); + if (isCancel(softPurge)) return null; + } + + // For wildcard paths, ask for confirmation + if (path && path.includes('*')) { + const shouldPurge = await confirm({ + message: `Are you sure you want to purge ${path}? This could affect multiple paths.`, + initialValue: false + }); + if (isCancel(shouldPurge) || !shouldPurge) return null; + } + + return { + path, + 'cache-keys': cacheKeys, + 'soft-purge': softPurge + }; + }, - client(config) - .purge(argv.path) - .then(response => console.log(chalk.green('Success:') + ` Purged ${argv.path}`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + const context = { + config: this.config || config, + client: this.client || (() => client(config)) + }; + + if (!await context.config.fromArgs(args)) { + process.exit(1); + } + + const quant = context.client(context.config); + + try { + const options = { + softPurge: args['soft-purge'] + }; + + if (args['cache-keys']) { + await quant.purge(null, args['cache-keys'], options); + return `Successfully purged cache keys: ${args['cache-keys']}`; + } else { + await quant.purge(args.path, null, options); + return `Successfully purged ${args.path}`; + } + } catch (err) { + throw new Error(`Failed to purge: ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/redirect.js b/src/commands/redirect.js index 8b8990a..1141297 100644 --- a/src/commands/redirect.js +++ b/src/commands/redirect.js @@ -1,34 +1,103 @@ /** - * Redirect a QuantCDN path to another. + * Create a redirect. * * @usage - * quant redirect --status + * quant redirect [status] */ -const chalk = require('chalk'); +const { text, select, isCancel } = require('@clack/prompts'); const config = require('../config'); const client = require('../quant-client'); +const isMD5Match = require('../helper/is-md5-match'); -const command = {}; +const command = { + command: 'redirect [status]', + describe: 'Create a redirect', + + builder: (yargs) => { + return yargs + .positional('from', { + describe: 'URL to redirect from', + type: 'string', + demandOption: true + }) + .positional('to', { + describe: 'URL to redirect to', + type: 'string', + demandOption: true + }) + .positional('status', { + describe: 'HTTP status code', + type: 'number', + default: 302, + choices: [301, 302, 303, 307, 308] + }); + }, -command.command = 'redirect [status] [author]'; -command.describe = 'Create a redirect'; -command.builder = (yargs) => { - yargs.default('status', 302); - yargs.default('author', null); - return yargs; -}; + async promptArgs(providedArgs = {}) { + let from = providedArgs.from; + if (!from) { + from = await text({ + message: 'Enter URL to redirect from', + validate: value => !value ? 'From URL is required' : undefined + }); + if (isCancel(from)) return null; + } -command.handler = function(argv) { - console.log(chalk.bold.green('*** Quant redirect ***')); + let to = providedArgs.to; + if (!to) { + to = await text({ + message: 'Enter URL to redirect to', + validate: value => !value ? 'To URL is required' : undefined + }); + if (isCancel(to)) return null; + } - // @TODO: Accept argv.dir. - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + let status = providedArgs.status; + if (!status) { + status = await select({ + message: 'Select HTTP status code', + options: [ + { value: 301, label: '301 - Permanent' }, + { value: 302, label: '302 - Found (Temporary)' }, + { value: 303, label: '303 - See Other' }, + { value: 307, label: '307 - Temporary Redirect' }, + { value: 308, label: '308 - Permanent Redirect' } + ], + initialValue: 302 + }); + if (isCancel(status)) return null; + } + + return { from, to, status }; + }, + + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } - client(config).redirect(argv.from, argv.to, argv.author, argv.status) - .then((body) => console.log(chalk.green('Success: ') + ` Added redirect ${from} to ${to}`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); + const context = { + config: this.config || config, + client: this.client || (() => client(config)) + }; + + if (!await context.config.fromArgs(args)) { + process.exit(1); + } + + const quant = context.client(context.config); + const status = args.status || 302; + + try { + await quant.redirect(args.from, args.to, null, status); + return `Created redirect from ${args.from} to ${args.to} (${status})`; + } catch (err) { + if (isMD5Match(err)) { + return `Skipped redirect from ${args.from} to ${args.to} (already exists)`; + } + throw new Error(`Failed to create redirect: ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/scan.js b/src/commands/scan.js index 96094f4..2292cfd 100644 --- a/src/commands/scan.js +++ b/src/commands/scan.js @@ -1,118 +1,302 @@ /** - * Delete content from the Quant API. - * - * @usage - * quant delete + * Scan local files and validate checksums. */ - -const chalk = require('chalk'); +const { text, confirm, isCancel } = require('@clack/prompts'); const config = require('../config'); const client = require('../quant-client'); -const yargs = require('yargs'); const getFiles = require('../helper/getFiles'); const path = require('path'); const md5File = require('md5-file'); +const revisions = require('../helper/revisions'); +const color = require('picocolors'); +const { chunk } = require('../helper/array'); -const command = {}; - -command.command = 'scan'; -command.describe = 'Validate local file checksums'; -command.builder = (yargs) => { - yargs.options('diff-only', { - describe: 'Show only source files different from Quant', - type: 'boolean', - default: false, - }); - yargs.options('unpublish-only', { - describe: 'Show only the unpublished results', - type: 'boolean', - default: false, - }); - yargs.options('skip-unpublish-regex', { - describe: 'Skip the unpublish process for specific regex', - type: 'string', - }); -}; +const command = { + command: 'scan [options]', + describe: 'Validate local file checksums', + + builder: (yargs) => { + return yargs + .option('diff-only', { + describe: 'Show only source files different from Quant', + type: 'boolean', + default: false + }) + .option('unpublish-only', { + describe: 'Show only the unpublished results', + type: 'boolean', + default: false + }) + .option('skip-unpublish', { + describe: 'Skip the unpublish process', + type: 'boolean', + default: false + }) + .option('skip-unpublish-regex', { + describe: 'Skip the unpublish process for specific regex', + type: 'string' + }); + }, -command.handler = async function(argv) { - config.fromArgs(argv); - const quant = client(config); + async promptArgs(providedArgs = {}) { + let diffOnly = providedArgs['diff-only']; + if (typeof diffOnly !== 'boolean') { + diffOnly = await confirm({ + message: 'Show only source files different from Quant?', + initialValue: false + }); + if (isCancel(diffOnly)) return null; + } - // Determine local file path. - const dir = argv.dir || config.get('dir'); - const p = path.resolve(process.cwd(), dir); + let unpublishOnly = providedArgs['unpublish-only']; + if (typeof unpublishOnly !== 'boolean') { + unpublishOnly = await confirm({ + message: 'Show only the unpublished results?', + initialValue: false + }); + if (isCancel(unpublishOnly)) return null; + } - console.log(chalk.bold.green('*** Quant scan ***')); + let skipUnpublishRegex = providedArgs['skip-unpublish-regex']; + if (skipUnpublishRegex === undefined) { + skipUnpublishRegex = await text({ + message: 'Enter regex pattern to skip unpublish (optional)', + }); + if (isCancel(skipUnpublishRegex)) return null; + } - try { - data = await quant.meta(true); - } catch (err) { - console.log('Something is not right.'); - yargs.exit(1); - } + return { + 'diff-only': diffOnly, + 'unpublish-only': unpublishOnly, + 'skip-unpublish-regex': skipUnpublishRegex || undefined + }; + }, - try { - files = await getFiles(p); - } catch (err) { - console.log(chalk.red(err.message)); - yargs.exit(1); - } + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } - const relativeFiles = []; + if (!await config.fromArgs(args)) { + process.exit(1); + } - files.map(async (file) => { - const filepath = path.relative(p, file); - let revision = false; - relativeFiles.push(`/${filepath.toLowerCase()}`); + const quant = client(config); + const buildDir = args.dir || config.get('dir') || 'build'; + const p = path.resolve(process.cwd(), buildDir); - if (argv['unpublish-only']) { - return; + console.log('Fetching metadata from Quant...'); + let metadata; + try { + metadata = await quant.meta(true); + console.log('Metadata fetched successfully'); + } catch (err) { + throw new Error('Failed to fetch metadata from Quant'); } + console.log('Scanning local files...'); + let files; try { - revision = await quant.revision(filepath); - } catch (err) {} - - if (!revision) { - console.log(`[info]: Unable to find ${filepath} in source.`); - return; + files = await getFiles(p); + console.log(`Found ${files.length} local files`); + } catch (err) { + throw new Error(err.message); } - const localmd5 = md5File.sync(file); + const results = { + upToDate: [], + different: [], + notFound: [], + toUnpublish: [] + }; - if (revision.md5 == localmd5) { - if (!argv['diff-only']) { - console.log(chalk.green(`[info]: ${filepath} is up-to-date`)); + // Helper function to normalize paths for comparison + const normalizePath = (filepath) => { + // Convert to lowercase and ensure leading slash + let normalizedPath = '/' + filepath.toLowerCase(); + + // Special cases for HTML files + if (!args['enable-index-html'] && normalizedPath.endsWith('/index.html')) { + // Case 1: Root index.html -> / + if (normalizedPath === '/index.html') { + return '/'; + } + + // Case 2: Directory index.html -> directory path + if (normalizedPath.endsWith('/index.html')) { + return normalizedPath.slice(0, -11); // Remove index.html including the slash + } } - } else { - if (argv['diff-only']) { - console.log(chalk.yellow(`[info]: ${filepath} is different.`)); + + return normalizedPath; + }; + + // Initialize revision log + const projectName = config.get('project'); + const revisionLogPath = path.resolve(process.cwd(), `quant-revision-log_${projectName}`); + revisions.enabled(true); + revisions.load(revisionLogPath); + console.log(color.dim(`Using revision log: ${revisionLogPath}`)); + + // Check local files + const totalFiles = files.length; + const spinChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let spinIndex = 0; + + // Clear line helper + const clearLine = () => { + process.stdout.write('\x1b[2K\r'); + }; + + // Process files in batches + const batchSize = 20; + const batches = chunk(files, batchSize); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + const batchPaths = batch.map(file => { + const filepath = path.relative(p, file); + return normalizePath(filepath); + }); + + // Update progress + const progress = `[${i * batchSize + 1}-${Math.min((i + 1) * batchSize, files.length)}/${files.length}]`; + clearLine(); + const spinChar = spinChars[spinIndex]; + spinIndex = (spinIndex + 1) % spinChars.length; + process.stdout.write(`${spinChar} ${progress} Checking batch of files...`); + + try { + const response = await quant.batchMeta(batchPaths); + // Process each file in the batch + for (let j = 0; j < batch.length; j++) { + const file = batch[j]; + const filepath = path.relative(p, file); + let localPath = '/' + filepath.toLowerCase(); + + // Normalize path + localPath = normalizePath(filepath); + + const localmd5 = md5File.sync(file); + + // Find matching record in response + const record = response.global_meta.records.find(r => { + if (!r || !r.meta) return false; + const recordUrl = r.meta.url || ''; + const matches = recordUrl.toLowerCase() === localPath; + return matches; + }); + + if (record && record.meta.md5 === localmd5) { + if (!args['diff-only']) { + results.upToDate.push(filepath); + } + // Store matching MD5s in revision log + revisions.store({ + url: filepath, + md5: localmd5 + }); + } else if (record) { + results.different.push(filepath); + } else { + results.notFound.push(filepath); + } + } + } catch (err) { + // Fall back to checking files individually + for (const file of batch) { + const filepath = path.relative(p, file); + results.notFound.push(filepath); + } } } - }); - data.records.map(async (item) => { - const f = item.url.replace('/index.html', '.html'); - if (relativeFiles.includes(item.url) || relativeFiles.includes(f)) { - return; + // Find paths to unpublish from initial metadata + const processedUrls = new Set(); // Track what we've already processed + const skippedByRegex = new Set(); // Track files skipped by regex + + if (!args['skip-unpublish'] && metadata && metadata.records) { + const skipPattern = args['skip-unpublish-regex'] ? new RegExp(args['skip-unpublish-regex']) : null; + + metadata.records.forEach(record => { + if (!record.url || processedUrls.has(record.url)) return; + + // Skip redirects + if (record.type === 'redirect') { + return; + } + + // Skip if matches the skip pattern + if (skipPattern && skipPattern.test(record.url)) { + skippedByRegex.add(record.url); + return; + } + + // Check if file exists locally + const localFile = files.find(file => { + const filepath = path.relative(p, file); + const localPath = normalizePath(filepath); + const recordPath = record.url.toLowerCase(); + return localPath === recordPath || localPath === recordPath.replace(/^\//, ''); + }); + + if (!localFile) { + results.toUnpublish.push(record.url); + processedUrls.add(record.url); + } + }); } - if (item.type && item.type == 'redirect') { - return; + // Clear the last progress line + clearLine(); + process.stdout.write('\n'); + console.log('Scan completed'); + + // Save revision log + revisions.save(); + console.log(color.dim('Revision log updated')); + + // Format the results as a string + let output = '\nScan Results:\n'; + + if (results.upToDate.length > 0 && !args['diff-only']) { + output += color.green('\nUp to date:') + `\n${results.upToDate.join('\n')}\n`; } - // Skip unpublish process if skip unpublish regex matches. - if (argv['skip-unpublish-regex']) { - const match = item.url.match(argv['skip-unpublish-regex']); - if (match) { - return; - } + if (results.different.length > 0) { + output += color.yellow('\nDifferent:') + `\n${results.different.join('\n')}\n`; } - if (!argv['diff-only']) { - console.log(chalk.magenta(`[info]: ${item.url} is to be unpublished.`)); + + if (results.notFound.length > 0) { + output += color.red('\nNot found:') + `\n${results.notFound.join('\n')}\n`; } - }); + + if (results.toUnpublish.length > 0) { + output += color.magenta('\nTo be unpublished:') + `\n${results.toUnpublish.join('\n')}\n`; + } + + if (skippedByRegex.size > 0) { + output += color.blue('\nSkipped by regex:') + `\n${Array.from(skippedByRegex).join('\n')}\n`; + } + + if (output === '\nScan Results:\n') { + output += 'No changes found'; + } + + // Add summary + output += '\nSummary:\n'; + output += `Total files scanned: ${totalFiles}\n`; + output += `Up to date: ${results.upToDate.length}\n`; + output += `Different: ${results.different.length}\n`; + output += `Not found: ${results.notFound.length}\n`; + output += `To be unpublished: ${results.toUnpublish.length}\n`; + if (skippedByRegex.size > 0) { + output += `Skipped by regex: ${skippedByRegex.size}\n`; + } + + return output; + } }; module.exports = command; diff --git a/src/commands/search.js b/src/commands/search.js index 7408c73..63e8d05 100644 --- a/src/commands/search.js +++ b/src/commands/search.js @@ -1,136 +1,151 @@ /** - * Perform Search API oprtations. - * - * @usage - * quant search + * Perform Search API operations. */ -const chalk = require('chalk'); -const client = require('../quant-client'); +const { text, select, confirm, isCancel } = require('@clack/prompts'); +const color = require('picocolors'); const config = require('../config'); +const client = require('../quant-client'); const fs = require('fs'); -const glob = require('glob'); - -const command = {}; - -command.command = 'search '; -command.describe = 'Perform search index operations'; -command.builder = (yargs) => { - // Add/update search records. - yargs.command('index', '', { - command: 'index ', - builder: (yargs) => { - yargs.positional('path', { + +const command = { + command: 'search ', + describe: 'Perform search index operations', + + builder: (yargs) => { + return yargs + .positional('operation', { + describe: 'Operation to perform', type: 'string', - describe: 'Path to directory containing JSON files, or an individual JSON file.', + choices: ['status', 'index', 'unindex', 'clear'] + }) + .option('path', { + describe: 'Path to file or URL', + type: 'string' }); - }, - handler: (argv) => { - if (!argv.path) { - console.error(chalk.yellow('No path provided. Provide a path on disk, e.g: --path=/path/to/files')); - return; - } - - console.log(chalk.bold.green('*** Add/update search records ***')); - - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } - - let jsonFiles = []; - fs.stat(argv.path, (error, stats) => { - // incase of error - if (error) { - console.error(error); - return; + }, + + async promptArgs(providedArgs = {}) { + let operation = providedArgs.operation; + if (!operation) { + operation = await select({ + message: 'Select search operation', + options: [ + { value: 'status', label: 'Show search index status' }, + { value: 'index', label: 'Add/update items in search index' }, + { value: 'unindex', label: 'Remove item from search index' }, + { value: 'clear', label: 'Clear entire search index' } + ] + }); + if (isCancel(operation)) return null; + } + + // For clear operation, ask for confirmation + if (operation === 'clear') { + const shouldClear = await confirm({ + message: 'Are you sure you want to clear the entire search index? This cannot be undone.', + initialValue: false + }); + if (isCancel(shouldClear) || !shouldClear) return null; + } + + let path = providedArgs.path; + if ((operation === 'index' || operation === 'unindex') && !path) { + path = await text({ + message: operation === 'index' + ? 'Enter path to JSON file containing search data' + : 'Enter URL to remove from search index', + validate: value => !value ? 'Path is required' : undefined + }); + if (isCancel(path)) return null; + } + + // For index operation, validate JSON file + if (operation === 'index' && path) { + try { + const fileContent = fs.readFileSync(path, 'utf8'); + const data = JSON.parse(fileContent); + + // Validate array structure + if (!Array.isArray(data)) { + throw new Error('JSON must be an array of records'); } - if (stats.isDirectory()) { - jsonFiles = glob.sync(argv.path + '/*.json'); - } else { - jsonFiles = [argv.path]; - } + // Validate required fields + data.forEach((record, index) => { + if (!record.url || !record.title || !record.content) { + throw new Error(`Record at index ${index} missing required fields (url, title, content)`); + } + }); - for (let i = 0; i < jsonFiles.length; i++) { - client(config) - .searchIndex(jsonFiles[i]) - .then(response => console.log(chalk.green('Success:') + ` Successfully posted search records in ${jsonFiles[i]}`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - } - }); - }, - }); - - // Unindex/remove search record. - yargs.command('unindex', '', { - command: 'unindex ', - describe: 'Removes an item from the search index based on URL.', - builder: (yargs) => { - yargs.positional('path', { - type: 'string', - describe: 'URL path of the item to unindex.', - }); + } catch (err) { + if (err.code === 'ENOENT') { + console.log(color.red(`Error: File not found: ${path}`)); + } else { + console.log(color.red('Error: Invalid JSON file')); + console.log(color.yellow('Make sure your JSON:')); + console.log('1. Uses double quotes for property names'); + console.log('2. Has valid syntax (no trailing commas)'); + console.log('3. Follows the required format:'); + console.log(color.dim(` +[ + { + "title": "This is a record", + "url": "/blog/page", + "summary": "The record is small and neat.", + "content": "Lots of good content here. But not too much!" }, - handler: (argv) => { - if (!argv.path) { - console.error(chalk.yellow('No path provided. Provide a content URL path to remove from index, e.g: /about-us')); - return; + { + "title": "Fully featured search record", + "url": "/about-us", + "summary": "The record contains all the trimmings.", + "content": "Lorem ipsum dolor sit amet...", + "image": "https://www.example.com/images/about.jpg", + "categories": [ "Blog", "Commerce" ], + "tags": [ "Tailwind", "QuantCDN" ] + } +]`)); + console.log('\nError details:', err.message); + } + return null; } + } - console.log(chalk.bold.green('*** Remove search record ***')); + return { operation, path }; + }, - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } - client(config) - .searchRemove(argv.path) - .then(response => console.log(chalk.green('Success:') + ` Successfully removed search record for ${argv.path}`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - }, - }); + if (!await config.fromArgs(args)) { + process.exit(1); + } - // Display search index status. - yargs.command('status', '', { - command: 'status', - describe: 'Display search index status', - builder: (yargs) => { - }, - handler: (argv) => { - console.log(chalk.bold.green('*** Search index status ***')); + const quant = client(config); - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + try { + switch (args.operation) { + case 'status': + const status = await quant.searchStatus(); + return `Search index status:\nTotal documents: ${status.index && status.index.entries || 0}`; - client(config) - .searchStatus() - .then(response => { - console.log(chalk.green('Success:') + ` Successfully retrieved search index status`); - console.log(response); - }) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - }, - }); + case 'index': + await quant.searchIndex(args.path); + return 'Successfully added items to search index'; - // Clear the entire search index. - yargs.command('clear', '', { - command: 'clear', - describe: 'Clears the entire search index', - builder: (yargs) => { - }, - handler: (argv) => { - console.log(chalk.bold.green('*** Clear search index ***')); + case 'unindex': + await quant.searchRemove(args.path); + return `Successfully removed ${args.path} from search index`; - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); + case 'clear': + await quant.searchClearIndex(); + return 'Successfully cleared search index'; } - - client(config) - .searchClearIndex() - .then(response => console.log(chalk.green('Success:') + ` Successfully cleared search index`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); - }, - }); + } catch (err) { + throw new Error(`Failed to ${args.operation}: ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/unpublish.js b/src/commands/unpublish.js index f686e2a..9bf4296 100644 --- a/src/commands/unpublish.js +++ b/src/commands/unpublish.js @@ -4,28 +4,117 @@ * @usage * quant unpublish */ -const chalk = require('chalk'); -const client = require('../quant-client'); +const { text, isCancel } = require('@clack/prompts'); +const color = require('picocolors'); const config = require('../config'); +const client = require('../quant-client'); -const command = {}; +const command = { + command: 'unpublish ', + describe: 'Unpublish an asset', + + builder: (yargs) => { + return yargs + .positional('path', { + describe: 'Path to unpublish', + type: 'string', + demandOption: true + }); + }, -command.command = 'unpublish '; -command.describe = 'Unpublish an asset'; -command.builder = {}; + async promptArgs(providedArgs = {}) { + let path = providedArgs.path; + if (!path) { + path = await text({ + message: 'Enter the path to unpublish', + validate: value => !value ? 'Path is required' : undefined + }); + if (isCancel(path)) return null; + } -command.handler = function(argv) { - console.log(chalk.bold.green('*** Quant unpublish ***')); + return { path }; + }, - // config.fromArgs(argv); - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + const context = { + config: this.config || config, + client: this.client || (() => client(config)) + }; + + if (!await context.config.fromArgs(args)) { + process.exit(1); + } - client(config) - .unpublish(argv.path) - .then(response => console.log(chalk.green('Success:') + ` Unpublished successfully`)) - .catch((err) => console.log(chalk.red.bold('Error:') + ` ${err}`)); + const quant = context.client(context.config); + + try { + const response = await quant.unpublish(args.path); + + // Check if the response indicates success + if (response && !response.error) { + return color.green(`Successfully unpublished [${args.path}]`); + } + + throw new Error(`Unexpected response format: ${JSON.stringify(response, null, 2)}`); + } catch (err) { + // If we have a response in the error message, try to parse it + try { + if (err.response?.data) { + const data = err.response.data; + + // Check for specific error messages + if (data.errorMsg) { + if (data.errorMsg.includes('not found') || data.errorMsg.includes('does not exist')) { + return color.dim(`Path [${args.path}] does not exist or is already unpublished`); + } + if (data.errorMsg.includes('already unpublished')) { + return color.dim(`Path [${args.path}] was already unpublished`); + } + } + + // If we have a 400 status, it's likely the path doesn't exist + if (err.response.status === 400) { + return color.dim(`Path [${args.path}] does not exist or is already unpublished`); + } + + throw new Error(`Failed to unpublish: ${err.message}\nResponse: ${JSON.stringify(data, null, 2)}`); + } + + const match = err.message.match(/Response: (.*)/s); + if (match) { + const responseData = JSON.parse(match[1]); + + // Check if this was actually a successful unpublish + if (!responseData.error) { + return color.green(`Successfully unpublished [${args.path}]`); + } + + // Check if the path was already unpublished + if (responseData.errorMsg) { + if (responseData.errorMsg.includes('not found') || responseData.errorMsg.includes('does not exist')) { + return color.dim(`Path [${args.path}] does not exist or is already unpublished`); + } + if (responseData.errorMsg.includes('already unpublished')) { + return color.dim(`Path [${args.path}] was already unpublished`); + } + } + } + } catch (parseError) { + // If we can't parse the response, continue with original error + } + + // For actual errors + if (err.response?.status === 404) { + return color.dim(`Path [${args.path}] does not exist or is already unpublished`); + } + + throw new Error(`Failed to unpublish: ${err.message}`); + } + } }; module.exports = command; diff --git a/src/commands/waflogs.js b/src/commands/waflogs.js index 29aa9d3..b6d4445 100644 --- a/src/commands/waflogs.js +++ b/src/commands/waflogs.js @@ -1,71 +1,205 @@ /** - * Provides access to the WAF logs for a project. + * Access WAF logs. * * @usage - * quant waf-logs + * quant waflogs */ - -const chalk = require('chalk'); -const client = require('../quant-client'); +const { text, confirm, isCancel } = require('@clack/prompts'); const config = require('../config'); -const papa = require('papaparse'); -const fs = require('fs'); - -const command = {}; - -command.command = 'waf:logs'; -command.describe = 'Access a projects WAF logs'; -command.builder = (yargs) => { - yargs.option('fields', { - alias: 'f', - describe: 'CSV of field names to show for the logs', - type: 'string', - }); - yargs.option('output', { - alias: 'o', - describe: 'Location to write CSV output', - type: 'string', - }); - yargs.option('all', { - describe: 'Fetch all logs from the server', - type: 'boolean', - default: false, - }); - yargs.option('size', { - describe: 'Number of logs to return per request', - type: 'integer', - default: 10, - }); -}; +const client = require('../quant-client'); -command.handler = async function(argv) { - console.log(chalk.bold.green('*** Quant WAF Logs***')); +const command = { + command: 'waflogs', + describe: 'Access project WAF logs', + + builder: (yargs) => { + return yargs + .option('fields', { + alias: 'f', + describe: 'CSV of field names to show for the logs', + type: 'string' + }) + .option('output', { + alias: 'o', + describe: 'Location to write CSV output', + type: 'string' + }) + .option('all', { + describe: 'Fetch all logs from the server', + type: 'boolean', + default: false + }) + .option('size', { + describe: 'Number of logs to return per request', + type: 'number', + default: 10 + }); + }, - if (!config.fromArgs(argv)) { - return console.error(chalk.yellow('Quant is not configured, run init.')); - } + async promptArgs(providedArgs = {}) { + // If all is provided, skip that prompt + let fetchAll = providedArgs.all; + if (typeof fetchAll !== 'boolean') { + fetchAll = await confirm({ + message: 'Fetch all logs from the server?', + initialValue: false + }); + if (isCancel(fetchAll)) return null; + } - const quant = client(config); - let fields = argv.fields; + // If size is provided, skip that prompt + let size = providedArgs.size; + if (!size) { + const sizeInput = await text({ + message: 'Number of logs to return per request', + defaultValue: '10', + validate: value => { + const num = parseInt(value); + if (isNaN(num) || num < 1) { + return 'Please enter a valid number'; + } + } + }); + if (isCancel(sizeInput)) return null; + size = parseInt(sizeInput); + } - if (fields) { - fields = fields.split(','); - } + // If fields is provided, skip that prompt + let fields = providedArgs.fields; + if (fields === undefined) { + fields = await text({ + message: 'Enter comma-separated field names to show (optional)', + }); + if (isCancel(fields)) return null; + // Don't return empty string + fields = fields || null; + } - console.log(chalk.gray('Fetching log data...')); + // If output is provided, skip that prompt + let output = providedArgs.output; + if (output === undefined) { + output = await text({ + message: 'Location to write CSV output (optional)', + }); + if (isCancel(output)) return null; + // Don't return empty string + output = output || null; + } - logs = await quant.wafLogs(argv.all, {per_page: argv.size}); + return { + all: fetchAll, + size, + fields: fields ? (typeof fields === 'string' ? fields.split(',') : fields) : null, + output: output || null + }; + }, - if (logs === -1) { - console.log(chalk.red('Invalid credentials provided, please check your token has access.')); - return; - } + async handler(args) { + if (!args) { + throw new Error('Operation cancelled'); + } + + // Check for optional arguments and prompt if not provided + if (!args.fields && !args.output && args.all === undefined && !args.size) { + const promptedArgs = await this.promptArgs(args); + if (!promptedArgs) { + throw new Error('Operation cancelled'); + } + args = { ...args, ...promptedArgs }; + } + + if (!await config.fromArgs(args)) { + process.exit(1); + } + + const quant = client(config); + + try { + + let allLogs = []; + let currentPage = 1; + let totalPages = 1; + + do { + const response = await quant.wafLogs(args.all, { + page_size: args.size, + page: currentPage + }); + + if (response === -1) { + throw new Error('Invalid credentials provided, please check your token has access'); + } + + allLogs = allLogs.concat(response.records); + totalPages = response.total_pages; + currentPage++; + + if (args.all && currentPage <= totalPages) { + console.log(`Fetching page ${currentPage} of ${totalPages}...`); + } + } while (args.all && currentPage <= totalPages); + + if (args.output) { + try { + fs.writeFileSync(args.output, papa.unparse(allLogs)); + return `Logs saved to ${args.output}`; + } catch (err) { + throw new Error(`Failed to write output file: ${err.message}`); + } + } + + // Format the logs output + if (!allLogs || allLogs.length === 0) { + return 'No logs found'; + } + + let output = `Found ${allLogs.length} logs\n`; + + if (args.fields) { + const fields = typeof args.fields === 'string' ? args.fields.split(',') : args.fields; + allLogs.forEach(log => { + output += '\n---\n'; + fields.forEach(field => { + if (log[field] !== undefined) { + output += `${field}: ${log[field]}\n`; + } + }); + }); + } else { + allLogs.forEach(log => { + output += '\n---\n'; + Object.entries(log).forEach(([key, value]) => { + if (key === 'meta') { + try { + const meta = JSON.parse(value); + Object.entries(meta).forEach(([metaKey, metaValue]) => { + output += `${metaKey}: ${metaValue}\n`; + }); + } catch (e) { + output += `${key}: ${value}\n`; + } + } else { + output += `${key}: ${value}\n`; + } + }); + }); + } + + return output; - console.table(logs, fields); + } catch (err) { + // Format a user-friendly error message + let errorMessage = 'Failed to fetch WAF logs: '; + if (err.code === 'ECONNREFUSED') { + errorMessage += 'Could not connect to the API server'; + } else if (err.response && err.response.data) { + errorMessage += `${err.message}\nResponse: ${JSON.stringify(err.response.data, null, 2)}`; + } else { + errorMessage += err.message; + } - if (argv.output) { - fs.writeFileSync(argv.output, papa.unparse(logs)); - console.log(`Saved output to ${argv.output}`); + throw new Error(errorMessage); + } } }; diff --git a/src/config.js b/src/config.js index 2c6ce1a..2e7b934 100644 --- a/src/config.js +++ b/src/config.js @@ -1,149 +1,131 @@ const fs = require('fs'); +const os = require('os'); +const path = require('path'); -const filename = 'quant.json'; - -const config = { - dir: 'build', - endpoint: 'https://api.quantcdn.io', - clientid: null, - project: null, - token: null, - bearer: null, -}; - -/** - * Set the configuration values. - * - * @param {object} results - * - * @return {boolean} - * If the configuration was updated. - */ -const set = function(results) { - Object.assign(config, results); - return true; -}; - -/** - * Getter for the configuration. - * - * @param {string} key - * The configuration key. - * - * @return {string} - * The configuration value. - */ -const get = function(key) { - let value = config[key]; - if (key == 'endpoint') { - value += '/v1'; - } - return value; -}; +let config = {}; /** - * Save the configuration to file. - * - * @param {string} dir - * The directory to save to. - * - * @return {boolean} - * If the file was saved. + * Load configuration from various sources in order of precedence: + * 1. Command line arguments + * 2. Environment variables + * 3. quant.json file + * 4. Default values */ -const save = function(dir = '.') { - const data = JSON.stringify(config, null, 2); +async function fromArgs(args = {}, silent = false) { + // First check environment variables + const envConfig = { + clientid: process.env.QUANT_CLIENT_ID, + project: process.env.QUANT_PROJECT, + token: process.env.QUANT_TOKEN, + endpoint: process.env.QUANT_ENDPOINT, + dir: process.env.QUANT_DIR + }; + + // Then try to load from quant.json + let fileConfig = {}; try { - fs.writeFileSync(`${dir}/${filename}`, data); + fileConfig = JSON.parse(fs.readFileSync('quant.json')); } catch (err) { - return false; + // Silent fail - we'll handle missing config later } - return true; -}; - -/** - * Validate the loaded configuration. - * - * @return {boolean} - * If the configuration is valid. - */ -const validate = function() { - // Dir is optional as this can be an optional arg to relevant commands. - const reqKeys = ['clientid', 'project', 'endpoint', 'token']; - const diff = reqKeys.filter((i) => ! Object.keys(config).includes(i)); - return diff.length == 0; -}; -/** - * Load the configuration to memory. - * - * @param {string} dir - * The directory to load from. - * - * @return {boolean} - * If the file was loaded. - */ -const load = function(dir = '.') { - let data; - - try { - data = fs.readFileSync(`${dir}/${filename}`); - } catch (err) { - return false; + // Set defaults + const defaults = { + endpoint: 'https://api.quantcdn.io/v1', + dir: 'build' + }; + + // Merge configs with precedence: CLI args > env > file > defaults + config = { + ...defaults, + ...fileConfig, + ...Object.fromEntries( + Object.entries(envConfig).filter(([_, v]) => v !== undefined) + ) + }; + + // Handle CLI args and their aliases + if (args.dir) config.dir = args.dir; + if (args.endpoint || args.e) config.endpoint = args.endpoint || args.e; + if (args.clientid || args.c) config.clientid = args.clientid || args.c; + if (args.project || args.p) config.project = args.project || args.p; + if (args.token || args.t) config.token = args.token || args.t; + + // Handle enable-index-html setting + if (args['enable-index-html'] !== undefined) { + const enableIndexHtml = args['enable-index-html'] === true || args['enable-index-html'] === ''; + + // If setting exists in config, ensure it matches + if (config.enableIndexHtml !== undefined && + config.enableIndexHtml !== enableIndexHtml) { + const currentSetting = config.enableIndexHtml ? 'enabled' : 'disabled'; + const requestedSetting = enableIndexHtml ? 'enable' : 'disable'; + throw new Error( + `Cannot ${requestedSetting} index.html URLs - this project was deployed with index.html URLs ${currentSetting}` + ); + } + + // Store the setting if it's the first time + if (config.enableIndexHtml === undefined) { + config.enableIndexHtml = enableIndexHtml; + save(); + } } - data = JSON.parse(data); - Object.assign(config, data); - - if (!validate()) { - return false; + // Check required config + const missingConfig = []; + if (!config.clientid) missingConfig.push('clientid'); + if (!config.project) missingConfig.push('project'); + if (!config.token) missingConfig.push('token'); + + if (missingConfig.length > 0 && !silent) { + const color = require('picocolors'); + console.log(color.red('Missing required configuration:')); + console.log(color.yellow(`Missing: ${missingConfig.join(', ')}`)); + console.log('\nYou can provide configuration in several ways:'); + console.log('1. Run "quant init" to create a new configuration'); + console.log('2. Create a quant.json file in this directory'); + console.log('3. Set environment variables:'); + console.log(' - QUANT_CLIENT_ID'); + console.log(' - QUANT_PROJECT'); + console.log(' - QUANT_TOKEN'); + console.log('4. Provide via command line arguments:'); + console.log(' --clientid, -c'); + console.log(' --project, -p'); + console.log(' --token, -t'); } - return true; -}; + return missingConfig.length === 0; +} -/** - * Load a configuration object from argv. - * - * @param {yargs} argv - * yargs argv object. - * - * @return {boolean} - * If config is valid. - */ -const fromArgs = function(argv) { - load(); +function get(key) { + return config[key]; +} - if (argv.clientid) { - config.clientid = argv.clientid; - } +function set(values) { + config = {...config, ...values}; +} - if (argv.project) { - config.project = argv.project; +function save() { + const configDir = `${os.homedir()}/.quant`; + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, {recursive: true}); } - if (argv.token) { - config.token = argv.token; - } + const saveConfig = {...config}; - if (argv.endpoint) { - config.endpoint = argv.endpoint; - } + // Save to both global and local config + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify(saveConfig, null, 2) + ); - if (argv.bearer) { - config.bearer = argv.bearer; - } - - if (!validate()) { - return false; - } - - return true; -}; + fs.writeFileSync('quant.json', JSON.stringify(saveConfig, null, 2)); +} module.exports = { - save, - load, - set, - get, fromArgs, + get, + set, + save, }; diff --git a/src/crawl/callbacks.js b/src/crawl/callbacks.js deleted file mode 100644 index 8b216d1..0000000 --- a/src/crawl/callbacks.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Determine if a path needs ot be - */ -const chalk = require('chalk'); - -module.exports = { - redirectHandler: async (quant, queueItem, redirectQueueItem) => { - let path = queueItem.path; - - if (path.substr(-1) === '/' && path.length > 1) { - // Strip trailing slashes except if only / is present in the path. - path = path.substr(0, path.length - 1); - } - - if (queueItem.path == redirectQueueItem.path) { - return; - } - - if (path != queueItem.path) { - console.log(chalk.bold.green('✅ REDIRECT:') + ` ${queueItem.path} => ${path}`); - try { - await quant.redirect(queueItem.path, path, 'quant-cli', queueItem.stateData.code || 301); - } catch (err) {} - } else { - const destination = queueItem.host == redirectQueueItem.host ? redirectQueueItem.path : redirectQueueItem.url; - console.log(chalk.bold.green('✅ REDIRECT:') + ` ${path} => ${destination}`); - try { - await quant.redirect(path, destination, 'quant-cli', redirectQueueItem.stateData.code || 301); - } catch (err) {} - } - }, -}; diff --git a/src/crawl/detectors/images.js b/src/crawl/detectors/images.js deleted file mode 100644 index a4498ac..0000000 --- a/src/crawl/detectors/images.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Service to assist with find image patterns in string. - */ - -const {decode} = require('html-entities'); -const matchAll = require('string.prototype.matchall'); -const bgImg = /background(-image)?:.*?url\(\s*(?.*?)\s*\)/gi; -const dataSrc = /data-src(?:\-retina)?=\"(?[^']*?)\"/gi; -const xlinkHref = /xlink:href=\"(?.*?)\"/gi; - -module.exports = { - applies: (response) => { - return response.headers['content-type'] && (response.headers['content-type'].includes('text/html') || response.headers['content-type'].includes('css')); - }, - handler: (string, host, protocol = 'https') => { - if (Buffer.isBuffer(string)) { - string = string.toString(); - } - - const items = [...matchAll(string, bgImg), ...matchAll(string, dataSrc), ...matchAll(string, xlinkHref)]; - - if (items.length < 0) { - return []; - } - - return items.map((item) => { - let img = decode(item.groups.url).replace(/'|\"/g, ''); - if (host) { - img = `${protocol}://${host}${img}`; - } - return img; - }).filter((item, index, arr) => arr.indexOf(item) === index); - }, -}; diff --git a/src/crawl/detectors/index.js b/src/crawl/detectors/index.js deleted file mode 100644 index 179b4e6..0000000 --- a/src/crawl/detectors/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Directory loader for DOM filters. - */ -const path = require('path'); -require('fs').readdirSync(__dirname).forEach(function(file) { - if (file === 'index.js') { - return; - } - /* Store module with its name (from filename) */ - module.exports[path.basename(file, '.js')] = require(path.join(__dirname, file)); -}); diff --git a/src/crawl/detectors/responsiveImg.js b/src/crawl/detectors/responsiveImg.js deleted file mode 100644 index d6b0642..0000000 --- a/src/crawl/detectors/responsiveImg.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Service to parse and support responsive images. - */ - -const matchAll = require('string.prototype.matchall'); - -/** - * Format the source set to something that we can work with. - * - * @param {string} src - * The string to format. - * - * @return {string} - * Formatted source set. - */ -function prepare(src) { - return src.trim().split(' ')[0].replace(/^\//, ''); -} - -const picp = new RegExp(/[^<]*(?:[^<]*)*[^<]*(?:<[^<]*)*<\/picture>/gi); -const imgp = new RegExp(/]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/gi); -const srcs = new RegExp(/(?:srcset|src)="(?.[^"]+)"/gi); - -module.exports = { - applies: (response) => { - return response.headers['content-type'] && response.headers['content-type'].includes('text/html'); - }, - handler: (string, host, protocol = 'https') => { - const retval = []; - - if (Buffer.isBuffer(string)) { - string = string.toString(); - } - - const items = [...matchAll(string, picp), ...matchAll(string, imgp)]; - - if (items.length < 0) { - return retval; - } - - items.map((item) => { - const imgs = [...matchAll(item[0], srcs)]; - imgs.map((i) => { - i.groups.attr.split(',').map((a) => { - a = prepare(a); - if (host) { - url = new URL(a, `${protocol}://${host}/`); - a = `${url.origin}${url.pathname}`; - } - if (retval.indexOf(a) === -1) { - retval.push(a); - } - }); - }); - }); - - return retval; - }, -}; diff --git a/src/crawl/filters/index.js b/src/crawl/filters/index.js deleted file mode 100644 index 179b4e6..0000000 --- a/src/crawl/filters/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Directory loader for DOM filters. - */ -const path = require('path'); -require('fs').readdirSync(__dirname).forEach(function(file) { - if (file === 'index.js') { - return; - } - /* Store module with its name (from filename) */ - module.exports[path.basename(file, '.js')] = require(path.join(__dirname, file)); -}); diff --git a/src/crawl/filters/relativeDomains.js b/src/crawl/filters/relativeDomains.js deleted file mode 100644 index 7842698..0000000 --- a/src/crawl/filters/relativeDomains.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * The string buffer of a response. - * - * @param {string} dom - * The DOM string for markup. - * @param {object} opts - * The options. - * - * @return {string} - * The manipulated string. - */ -module.exports = { - option: 'rewrite', - handler: (dom, opts, argv = []) => { - const regex = new RegExp(`http[s]?:\/\/${opts.host}(:\\d+)?\/[\/]?`, 'gi'); - let body = dom.replace(regex, '/'); - - if ('extra-domains' in argv) { - let r; - const extraDomains = argv['extra-domains'].split(',').map((d) => d.trim()); - - for (let i = 0; i < extraDomains.length; i++) { - r = new RegExp(`http[s]?:\/\/${extraDomains[i]}(:\\d+)?\/[\/]?`, 'gi'); - body = body.replace(r, '/'); - } - } - - return body; - }, -}; diff --git a/src/helper/is-md5-match.js b/src/helper/is-md5-match.js new file mode 100644 index 0000000..018ea3a --- /dev/null +++ b/src/helper/is-md5-match.js @@ -0,0 +1,28 @@ +/** + * Check if an error indicates an MD5 match + * + * @param {Error} error - The error to check + * @returns {boolean} - True if the error indicates an MD5 match + */ +function isMD5Match(error) { + if (!error) return false; + + // Check for any kind of MD5 match message + if (error.response && error.response.data && error.response.data.errorMsg) { + if (error.response.data.errorMsg === 'MD5 already matches existing file.' || + error.response.data.errorMsg.includes('Published version already has md5')) { + return true; + } + } + + if (error.message) { + if (error.message.includes('Published version already has md5') || + error.message.includes('MD5 already matches')) { + return true; + } + } + + return false; +} + +module.exports = isMD5Match; \ No newline at end of file diff --git a/src/helper/revisions.js b/src/helper/revisions.js index 3f5471c..c4a3b31 100644 --- a/src/helper/revisions.js +++ b/src/helper/revisions.js @@ -1,123 +1,65 @@ const fs = require('fs'); -const os = require('os'); -const {tmpdir} = require('os'); -const {sep} = require('path'); +const path = require('path'); -let data = {}; -let isEnabled = true; -let loc = tmpdir() + sep + 'quant-revision-log'; +let isEnabled = false; +let revisions = {}; +let revisionFile; -/** - * Check if the sha exists in the revision log. - * - * @param {string} path - * Revision path to evaluate. - * @param {string} sha - * Revision sha to evaluate. - * - * @return {boolean} - * If the revision exists. - */ -const has = function(path, sha) { - if (!isEnabled) { - // Defaults to false if the local file is not enabled. - return false; +function load(file) { + // If no file path provided, use current working directory + if (!file) { + const projectName = require('../config').get('project'); + file = path.resolve(process.cwd(), `quant-revision-log_${projectName}`); } - if (!data.hasOwnProperty(path)) { - path = path.startsWith('/') ? path : `/${path}`; - if (!data.hasOwnProperty(path)) { - return false; - } - } + revisionFile = file; - return data[path] == sha; -}; - -/** - * Load the revision log into memory for this process. - * - * @param {string} file - * File location that stores the revision log. - * - * @return {boolean} - * If the log file was found and loaded. - */ -const load = function(file) { - if (!isEnabled) { - return; + try { + const data = fs.readFileSync(revisionFile); + revisions = JSON.parse(data); + } catch (err) { + // File doesn't exist or is invalid JSON - start with empty revisions + revisions = {}; } +} - let _d; - - if (file) { - loc = file; +function save() { + if (!revisionFile) { + const projectName = require('../config').get('project'); + revisionFile = path.resolve(process.cwd(), `quant-revision-log_${projectName}`); } - if (fs.existsSync(loc)) { - _d = fs.readFileSync(loc); - try { - _d = JSON.parse(_d.toString()); - } catch (err) { - return false; - } - } - data = Object.assign(data, _d); - return true; -}; + fs.writeFileSync(revisionFile, JSON.stringify(revisions, null, 2)); +} -/** - * Store an item in the log. - * - * @param {object} item - * An item to add to the revision log. - */ -const store = function(item) { +function store(meta) { if (!isEnabled) { return; } - const i = {}; - i[item.url] = item.md5; - data = Object.assign(data, i); -}; -/** - * Save the revision log. - * - * @return {boolean} - * If the file was saved. - */ -const save = function() { + revisions[meta.url] = meta; +} + +function has(url, md5) { if (!isEnabled) { - return; - } - try { - fs.writeFileSync(loc, JSON.stringify(data) + os.EOL); - } catch (err) { return false; } - return true; -}; -/** - * Get/set the state. - * - * @param {boolean} s - * The state. - * @return {boolean} - * The state. - */ -const enabled = function(s) { - if (s) { - isEnabled = s; + return revisions[url] && revisions[url].md5 === md5; +} + +function enabled(value = null) { + if (value !== null) { + isEnabled = value; } + return isEnabled; -}; +} module.exports = { - has, - store, load, save, + store, + has, enabled, }; diff --git a/src/helper/validate-uuid.js b/src/helper/validate-uuid.js new file mode 100644 index 0000000..6796ee7 --- /dev/null +++ b/src/helper/validate-uuid.js @@ -0,0 +1,17 @@ +/** + * UUID validation helper + * + * @param {string} uuid - The UUID to validate + * @returns {boolean} - True if valid UUID, false otherwise + */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[4-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function validateUUID(uuid) { + if (!uuid) return true; // Allow empty for new functions + return UUID_REGEX.test(uuid); +} + +module.exports = { + validateUUID, + UUID_REGEX +}; \ No newline at end of file diff --git a/src/quant-client.js b/src/quant-client.js index 63c266e..9d518e3 100644 --- a/src/quant-client.js +++ b/src/quant-client.js @@ -3,32 +3,43 @@ */ const axios = require("axios"); -const util = require("util"); const fs = require("fs"); const path = require("path"); const mime = require("mime-types"); const querystring = require("querystring"); const quantURL = require("./helper/quant-url"); -const client = function (config) { - const req = axios; - const get = axios.get; - const post = axios.post; - const patch = axios.patch; - const del = axios.delete; - +module.exports = function (config) { + // Set up headers with correct Quant header names const headers = { - "User-Agent": "Quant (+http://api.quantcdn.io)", - "Quant-Token": config.get("token"), - "Quant-Customer": config.get("clientid"), - "Quant-Organisation": config.get("clientid"), - "Quant-Project": config.get("project"), - "Content-Type": "application/json", + 'Content-Type': 'application/json', + 'Quant-Customer': config.get('clientid'), + 'Quant-Project': config.get('project'), + 'Quant-Token': config.get('token'), + 'Quant-Organisation': config.get('clientid') }; - if (config.get("bearer")) { - headers.Authorization = `Bearer ${config.get("bearer")}`; - } + // Create axios instance with dynamic baseURL + const client = axios.create({ + baseURL: config.get('endpoint') + }); + + // Helper functions for HTTP methods + const get = async (url, options = {}) => { + return await client.get(url, { ...options, headers: { ...headers, ...options.headers } }); + }; + + const post = async (url, data, options = {}) => { + return await client.post(url, data, { ...options, headers: { ...headers, ...options.headers } }); + }; + + const patch = async (url, data, options = {}) => { + return await client.patch(url, data, { ...options, headers: { ...headers, ...options.headers } }); + }; + + const del = async (url, options = {}) => { + return await client.delete(url, { ...options, headers: { ...headers, ...options.headers } }); + }; /** * Handle the response. @@ -40,6 +51,12 @@ const client = function (config) { * The API response. */ const handleResponse = function (response) { + // If this is an error response, format it + if (response.isAxiosError) { + const errorData = response.response && response.response.data ? response.response.data : {}; + throw new Error(formatError(errorData)); + } + const body = typeof response.data == "string" ? JSON.parse(response.data) @@ -50,28 +67,32 @@ const client = function (config) { for (i in body.errors) { msg += body.errors[i].errorMsg + "\n"; } - throw new Error(msg); + throw new Error(`${msg}\nResponse: ${JSON.stringify(body, null, 2)}`); } if (response.statusCode == 400) { - // @TODO: this is generally if the content is - // streamed to the endpoint 4xx and 5xx are thrown - // similarly, the API should respond with errors - // otherwise. if (typeof body.errorMsg != "undefined") { - throw new Error(body.errorMsg); + throw new Error(`${body.errorMsg}\nResponse: ${JSON.stringify(body, null, 2)}`); } - throw new Error("Critical error..."); + throw new Error(`Critical error...\nResponse: ${JSON.stringify(body, null, 2)}`); } if (body.error || (typeof body.errorMsg != "undefined" && body.errorMsg.length > 0)) { const msg = typeof body.errorMsg != "undefined" ? body.errorMsg : body.msg; - throw new Error(msg); + throw new Error(`${msg}\nResponse: ${JSON.stringify(body, null, 2)}`); } return body; }; + // Add this helper function for consistent error handling + function formatError(error) { + if (error.response && error.response.data) { + return `${error.message}\nResponse: ${JSON.stringify(error.response.data, null, 2)}`; + } + return error.message; + } + return { /** * Ping the quant API. @@ -82,9 +103,12 @@ const client = function (config) { * @throws Error. */ ping: async function () { - const url = `${config.get("endpoint")}/ping`; - const res = await get(url, { headers }); - return handleResponse(res); + try { + const res = await get(`/ping`); + return handleResponse(res); + } catch (error) { + throw new Error(formatError(error)); + } }, /** @@ -105,7 +129,7 @@ const client = function (config) { * @TODO * - Async iterator for memory 21k items ~ 40mb. */ - meta: async function (unfold = false, exclude = true, extend = {}) { + meta: async function (unfold = false, _exclude = true, extend = {}) { const records = []; const query = Object.assign( { @@ -319,7 +343,7 @@ const client = function (config) { file: async function ( local, location, - absolute = false, + _absolute = false, extraHeaders = {}, skipPurge = false, ) { @@ -470,56 +494,6 @@ const client = function (config) { return handleResponse(res); }, - /** - * Create a proxy with the Quant API. - * - * @param {string} url - * The relative URL to proxy. - * @param {string} destination - * The absolute FQDN/path to proxy to. - * @param {bool} published - * If the proxy is published - * @param {string} username - * Basic auth user. - * @param {string} password - * Basic auth password. - * - * @return {object} - * The response. - * - * @throws Error. - */ - proxy: async function ( - url, - destination, - published = true, - username, - password, - ) { - const options = { - url: `${config.get("endpoint")}/proxy`, - headers: { - ...headers, - }, - json: true, - body: { - url, - destination, - published, - }, - }; - - if (username) { - options.body.basic_auth_user = username; - options.body.basic_auth_pass = password; - } - - const res = await post(options.url, options.body, { - headers: options.headers, - }); - return handleResponse(res); - }, - /** * Delete a path from Quant. * @@ -589,39 +563,45 @@ const client = function (config) { * * @throws Error. */ - revisions: async function (url) { - url = url.indexOf("/") == 0 ? url : `/${url}`; - url = url.toLowerCase(); - url = url.replace(/\/?index\.html/, ""); - - const options = { - url: `${config.get("endpoint")}/revisions`, - headers: { - ...headers, - "Quant-Url": url, - }, - }; - const res = await get(options.url, { headers: options.headers }); - return handleResponse(res); + revisions: async function (path) { + try { + const response = await get(`${config.get('endpoint')}/meta/${path}`, { headers }); + return handleResponse(response); + } catch (error) { + throw error; + } }, /** - * Purge URL patterns from Quants Varnish. + * Purge content from the cache. * - * @param {string} urlPattern + * @param {string} url + * The URL to purge. + * @param {string} cacheKeys + * Space separated cache keys to purge. + * @param {object} options + * Additional options for the purge. * - * @throws Error + * @return {object} + * The response. + * + * @throws Error. */ - purge: async function (urlPattern) { - const options = { - url: `${config.get("endpoint")}/purge`, - headers: { - ...headers, - "Quant-Url": urlPattern, - }, - }; - const res = await post(options.url, {}, { headers: options.headers }); - return handleResponse(res); + purge: async function(url, cacheKeys, options = {}) { + const purgeHeaders = { ...headers }; + + if (cacheKeys) { + purgeHeaders['Cache-Keys'] = cacheKeys; + } else { + purgeHeaders['Quant-Url'] = url; + } + + if (options.softPurge) { + purgeHeaders['Soft-Purge'] = 'true'; + } + + const response = await post(`${config.get('endpoint')}/purge`, {}, { headers: purgeHeaders }); + return handleResponse(response); }, /** @@ -649,11 +629,12 @@ const client = function (config) { }, body: data, }; - const res = await post(options.url, options.body, { - headers: options.headers, - }); - - return handleResponse(res); + try { + const res = await post(options.url, options.body, { headers: options.headers }); + return handleResponse(res); + } catch (error) { + throw new Error(formatError(error)); + } }, /** @@ -671,9 +652,12 @@ const client = function (config) { "Quant-Url": url, }, }; - const res = await del(options.url, { headers: options.headers }); - - return handleResponse(res); + try { + const res = await del(options.url, { headers: options.headers }); + return handleResponse(res); + } catch (error) { + throw new Error(formatError(error)); + } }, /** @@ -689,9 +673,12 @@ const client = function (config) { }, json: true, }; - const res = await del(options.url, { headers: options.headers }); - - return handleResponse(res); + try { + const res = await del(options.url, { headers: options.headers }); + return handleResponse(res); + } catch (error) { + throw new Error(formatError(error)); + } }, /** @@ -707,9 +694,12 @@ const client = function (config) { }, json: true, }; - const res = await get(options.url, { headers: options.headers }); - - return handleResponse(res); + try { + const res = await get(options.url, { headers: options.headers }); + return handleResponse(res); + } catch (error) { + throw new Error(formatError(error)); + } }, /** @@ -723,66 +713,161 @@ const client = function (config) { * @return {object} * A list of all WAF logs. */ - wafLogs: async function (unfold = false, extend = {}) { - const logs = []; - const url = `${config.get("endpoint")}/waf/logs`; - const query = Object.assign( - { - per_page: 10, - }, - extend, - ); - const doUnfold = async function (i) { - let res; - query.page = i; - try { - res = await get(`${url}?${querystring.stringify(query)}`, { - headers, - }); - } catch (err) { - console.log(err); - return false; - } - if (res.data.data) { - logs.push(...res.data.data); + wafLogs: async function (_all = false, options = {}) { + try { + const response = await get(`${config.get('endpoint')}/waf/logs`, { + headers, + params: { + page_size: options.page_size || 10, + page: options.page || 1 + } + }); + return handleResponse(response); + } catch (error) { + throw error; + } + }, + + /** + * Get metadata for multiple URLs in a single request. + * + * @param {Array} urls + * List of URLs to check. + * + * @return {object} + * The response containing metadata for all URLs. + */ + batchMeta: async function(urls) { + try { + const options = { + url: `${config.get('endpoint')}/url-meta`, + headers: { + ...headers + }, + body: { + "Quant-Url": urls + } + }; + + const response = await post(options.url, options.body, { headers: options.headers }); + return handleResponse(response); + } catch (error) { + throw error; + } + }, + + /** + * Create or update an edge function. + * + * @param {string} file - Path to the function file + * @param {string} description - Description of the function + * @param {string} [uuid] - Optional UUID for updating existing function + * @returns {object} Response from the API + */ + edgeFunction: async function(file, description, uuid = null) { + if (!description) { + throw new Error("Description is required for edge functions"); + } + + if (!Buffer.isBuffer(file)) { + if (!fs.existsSync(file)) { + throw new Error("Function file is not accessible."); } - return res.data.next != ""; - }; + file = fs.readFileSync(file, 'utf8'); + } - const options = { - url: `${url}?${querystring.stringify(query)}`, - headers, + const body = { + content: file.toString(), + published: true, + desc: description }; - const res = await get(options, url, { headers: options.headers }); + if (uuid) { + body.uuid = uuid; + } - if (res.statusCode == 403) { - return -1; + try { + const response = await post(`${config.get('endpoint')}/functions`, body, { headers }); + return handleResponse(response); + } catch (error) { + throw error; } + }, - if ( - typeof res.data == "undefined" || - typeof res.data.data == "undefined" - ) { - return logs; + /** + * Create or update an edge filter function. + * + * @param {string} file - Path to the filter function file + * @param {string} description - Description of the filter + * @param {string} [uuid] - Optional UUID for updating existing filter + * @returns {object} Response from the API + */ + edgeFilter: async function(file, description, uuid = null) { + if (!description) { + throw new Error("Description is required for edge filters"); } - logs.push(...res.data.data); - let page = 1; - if (unfold && res.data.next != "") { - let more; - do { - page++; - more = await doUnfold(page); - } while (more && page <= 100); + if (!Buffer.isBuffer(file)) { + if (!fs.existsSync(file)) { + throw new Error("Filter function file is not accessible."); + } + file = fs.readFileSync(file, 'utf8'); + } + + const body = { + content: file.toString(), + published: true, + desc: description + }; + + if (uuid) { + body.uuid = uuid; + } + + try { + const response = await post(`${config.get('endpoint')}/functions/filter`, body, { headers }); + return handleResponse(response); + } catch (error) { + throw error; } - return logs; }, - }; -}; -module.exports = function () { - return module.exports.client.apply(this, arguments); -}; + /** + * Create or update an edge auth function. + * + * @param {string} file - Path to the auth function file + * @param {string} description - Description of the auth function + * @param {string} [uuid] - Optional UUID for updating existing auth function + * @returns {object} Response from the API + */ + edgeAuth: async function(file, description, uuid = null) { + if (!description) { + throw new Error("Description is required for auth functions"); + } + + if (!Buffer.isBuffer(file)) { + if (!fs.existsSync(file)) { + throw new Error("Auth function file is not accessible."); + } + file = fs.readFileSync(file, 'utf8'); + } + + const body = { + content: file.toString(), + published: true, + desc: description + }; + + if (uuid) { + body.uuid = uuid; + } -module.exports.client = client; + try { + const response = await post(`${config.get('endpoint')}/functions/auth`, body, { headers }); + return handleResponse(response); + } catch (error) { + throw error; + } + } + }; +}; diff --git a/test/client.test.js b/test/client.test.js deleted file mode 100644 index 9965d88..0000000 --- a/test/client.test.js +++ /dev/null @@ -1,1016 +0,0 @@ -/** - * Test the Quant client. - */ - -const client = require('../src/quant-client'); -const config = require('../src/config'); - -// Stubbable. -const axios = require('axios'); -const fs = require('fs'); - -const headers = { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', -}; - -describe('Quant Client', function () { - let cget; - let requestGet; - let requestPost; - let requestPatch; - - let chai, sinon, assert, expect; - - beforeEach(async () => { - chai = await import('chai'); - sinon = await import('sinon'); - assert = chai.assert; - expect = chai.expect; - - cget = sinon.stub(config, 'get'); - cget.withArgs('endpoint').returns('http://localhost:8081'); - cget.withArgs('clientid').returns('dev'); - cget.withArgs('token').returns('test'); - cget.withArgs('project').returns('test'); - }); - - afterEach(function () { - cget.restore(); - sinon.restore(); - }); - - describe('GET /ping', function () { - afterEach(function () { - requestGet.restore(); - }); - - it('should return a valid project', async function () { - const response = { - status: 200, - data: { - error: false, - project: 'test', - }, - headers: {}, - config: {}, - request: {}, - }; - - requestGet = sinon.stub(axios, 'get').resolves(response); - - const data = await client(config).ping(); - - assert.hasAnyKeys(data, 'project'); - assert.equal(data.project, 'test'); - expect(requestGet.calledOnceWith('http://localhost:8081/ping', { headers })).to.be.true; - }); - - it('should handle error responses', async function () { - const response = { - status: 403, - data: { - error: true, - errorMsg: 'Forbidden', - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'get').resolves(response); - - try { - await client(config).ping(); - } catch (err) { - assert.ok(true); - expect(requestGet.calledOnceWith('http://localhost:8081/ping', { headers })).to.be.true; - assert.typeOf(err, 'Error'); - assert.equal(err.message, 'Forbidden'); - return; - } - - assert.fail('Ping did not raise the error'); - }); - }); - - describe('POST /', function () { - let file; - let fr; - - beforeEach(function () { - // Set the directory so we can test for path inference. - cget.withArgs('dir').returns(process.cwd() + '/test/fixtures'); - file = sinon.stub(fs, 'createReadStream').returns({}); - fr = sinon.stub(fs, 'readFileSync').returns(''); - }); - - afterEach(function () { - requestPost.restore(); - file.restore(); - fr.restore(); - }); - - describe('send', function () { - it('should accept index.html files', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config) - .send('test/fixtures/index.html', false, true, false, true, true); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/index.html', - find_attachments: false, - content: '', - published: true - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - 'Quant-Skip-Purge': 'true' - } - } - ) - ).to.be.true; - }); - - it('should accept custom headers', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response) - - await client(config) - .send('test/fixtures/index.html', 'test/fixtures', true, false, false, false, { test: 'headers' }); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/test/fixtures', - find_attachments: false, - content: '', - published: true, - headers: { 'test': 'headers' }, - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - } - }, - ) - ).to.be.true; - }); - - it('should find attachments', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response) - - await client(config).send('test/fixtures/index.html', 'test/fixtures/index.html', true, true); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/test/fixtures', - find_attachments: true, - content: '', - published: true, - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - } - }, - ) - ).to.be.true; - }); - - it('should accept a location', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response) - - await client(config).send('test/fixtures/index.html', 'test/index.html'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/test', - find_attachments: false, - content: '', - published: true - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json' - } - } - ) - ).to.be.true; - }); - - it('should accept published status', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).send('test/fixtures/index.html', 'test/index.html', false); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/test', - find_attachments: false, - content: '', - published: false, - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - } - } - ) - ).to.be.true; - }); - - it('should accept html files', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).send('test/fixtures/some-file-path.html'); - - // Expect the post for the redirect. - expect( - requestPost.calledWith( - 'http://localhost:8081', - { - url: '/some-file-path.html', - find_attachments: false, - content: '', - published: true - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json' - } - } - ) - ).to.be.true; - }); - - it('should accept files', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response) - - const data = await client(config).file('test/fixtures/nala.jpg'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/nala.jpg', - } - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - }); - - describe('markup', function () { - it('should accept an index.html', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).markup('test/fixtures/index.html'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { url: '/index.html', content: '', published: true, find_attachments: false }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - } - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - it('should not accept other file types', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - try { - await client(config).markup('test/fixtures/test.js'); - } catch (err) { - assert.typeOf(err, 'Error'); - assert.equal(err.message, 'Can only upload an index.html file.'); - } - }); - it('should accept custom headers', async function () { - const response = { - statusCode: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'index.html', - errorMsg: '', - error: false, - }, - }; - requestPost = sinon.stub(axios, 'post').resolves(response) - - const data = await client(config).markup('test/fixtures/index.html', 'test/fixtures', true, false, { test: 'header' }); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - url: '/test/fixtures', - find_attachments: false, - content: '', - published: true, - headers: { 'test': 'header' }, - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'application/json', - } - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - }); - describe('files', function () { - it('should accept a local file', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/nala.jpg'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/nala.jpg', - } - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - - it('should accept custom headers', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/nala.jpg', 'nala.jpg', false, { test: 'headers' }); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/nala.jpg', - 'Quant-File-Headers': '{"test":"headers"}', - } - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - - it('should accept empty object', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/nala.jpg', 'nala.jpg', false, {}); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { - data: {} - }, - { - headers: { - 'User-Agent': 'Quant (+http://api.quantcdn.io)', - 'Quant-Token': 'test', - 'Quant-Customer': 'dev', - 'Quant-Organisation': 'dev', - 'Quant-Project': 'test', - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/nala.jpg', - }, - } - ), - ).to.be.true; - assert.equal(data, response.data); - }); - - it('should accept nested local files', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/sample/nala.jpg'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/sample/nala.jpg', - }, - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - it('should accept a custom location', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config) - .file('test/fixtures/sample/nala.jpg', '/path-to-file/nala.jpg'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/path-to-file/nala.jpg', - } - } - ), - ).to.be.true; - assert.equal(data, response.data); - }); - it('should accept a nested custom location', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'nala.jpg', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config) - .file('test/fixtures/sample/nala.jpg', '/path/to/file/nala.jpg'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/path/to/file/nala.jpg', - } - } - ), - ).to.be.true; - assert.equal(data, response.data); - }); - it('should accept css', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'test.css', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/test.css'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/test.css', - } - } - ), - ).to.be.true; - assert.equal(data, response.data); - }); - it('should accept js', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - md5: 'da697d6f9a318fe26d2dd75a6b123df0', - quant_filename: 'test.js', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - const data = await client(config).file('test/fixtures/test.js'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081', - { data: {} }, - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data', - 'Quant-File-Url': '/test.js', - }, - } - ) - ).to.be.true; - assert.equal(data, response.data); - }); - }); - }); - - describe('PATCH /unpublish', function () { - this.afterEach(function () { - requestPatch.restore(); - }); - - it('should remove index.html', async function () { - const response = { - status: 200, - data: { project: 'test' }, - headers: {}, - config: {}, - request: {}, - }; - requestPatch = sinon.stub(axios, 'patch').resolves(response); - - await client(config).unpublish('/path/to/index.html'); - expect( - requestPatch.calledOnceWith( - 'http://localhost:8081/unpublish', - {}, - { - headers: { - ...headers, - 'Quant-Url': '/path/to', - }, - } - ) - ).to.be.true; - }); - }); - - describe('POST /redirect', function () { - afterEach(function () { - requestPost.restore(); - }); - - it('should accept from and to', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - url: '/a', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).redirect('/a', '/b'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081/redirect', - { - url: '/a', - redirect_url: '/b', - redirect_http_code: 302, - published: true, - }, - { headers } - ), - ).to.be.true; - }); - - it('should accept status code', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - url: '/a', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).redirect('/a', '/b', 'test', 301); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081/redirect', - { - url: '/a', - redirect_url: '/b', - redirect_http_code: 301, - published: true, - info: { author_user: 'test' }, - }, - { headers } - ), - ).to.be.true; - }); - - it('should not accept an invalid http status code', async function () { - try { - await client(config).redirect('/a', '/b', 'test', 200); - } catch (err) { - assert.typeOf(err, 'Error'); - assert.equal(err.message, 'A valid redirect status code is required'); - } - try { - await client(config).redirect('/a', '/b', 'test', 401); - } catch (err) { - assert.typeOf(err, 'Error'); - assert.equal( - err.message, - 'A valid redirect status code is required', - ); - } - }); - }); - - describe('POST /proxy', function () { - afterEach(function () { - requestPost.restore(); - }); - - it('should accept url and destination as minimum', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - url: '/test', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).proxy( - '/test', - 'http://google.com', - ); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081/proxy', - { - url: '/test', - destination: 'http://google.com', - published: true, - }, - { headers } - ), - ).to.be.true; - }); - - it('should handle API errors', async function () { - const response = { - status: 403, - data: { - errorMsg: 'Forbidden', - error: true, - }, - headers: {}, - config: {}, - request: {}, - }; - requestPost = sinon.stub(axios, 'post').resolves(response); - - try { - await client(config).proxy('/test', 'http://google.com'); - } catch (err) { - assert.typeOf(err, 'Error'); - assert.equal(err.message, 'Forbidden'); - expect( - requestPost.calledOnceWith( - 'http://localhost:8081/proxy', - { - url: '/test', - destination: 'http://google.com', - published: true, - }, - { headers } - ) - ).to.be.true; - return; - } - }); - - it('should add basic auth', async function () { - const response = { - status: 200, - data: { - quant_revision: 1, - url: '/test', - errorMsg: '', - error: false, - }, - headers: {}, - config: {}, - request: {}, - }; - - requestPost = sinon.stub(axios, 'post').resolves(response); - - await client(config).proxy('/test', 'http://google.com', true, 'user', 'password'); - - expect( - requestPost.calledOnceWith( - 'http://localhost:8081/proxy', - { - url: '/test', - destination: 'http://google.com', - published: true, - basic_auth_user: 'user', - basic_auth_pass: 'password', - }, - { headers } - ), - ).to.be.true; - }); - }); -}); diff --git a/test/commands/deploy.test.js b/test/commands/deploy.test.js deleted file mode 100644 index 05b3f1e..0000000 --- a/test/commands/deploy.test.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Test the deploy command. - */ - -const path = require('path'); -const deploy = require('../../src/commands/deploy').handler; - -// Stubs -const getFiles = require('../../src/helper/getFiles'); -const config = require('../../src/config'); -const client = require('../../src/quant-client'); -const revisions = require('../../src/helper/revisions'); - -describe('Deploy', function() { - let getFilesStub; - let configGetStub; - let clientStub; - let meta; - let unpublish; - let ping; - let send; - - let chai, sinon, expect; - - // Disable console log for neater test output. - before(async () => { - chai = await import('chai'); - sinon = await import('sinon'); - expect = chai.expect; - sinon.stub(console, 'log') - }); - after(() => sinon.restore()); - - beforeEach(function() { - unpublish = sinon.stub(); - send = sinon.stub(); - ping = sinon.stub(); - - configGetStub = sinon.stub(config, 'get'); - configGetStub.withArgs('endpoint').returns('http://localhost:8081'); - configGetStub.withArgs('clientid').returns('dev'); - configGetStub.withArgs('token').returns('test'); - - getFilesStub = sinon.stub(getFiles, 'getFiles'); - }); - - afterEach(function() { - configGetStub.restore(); - getFilesStub.restore(); - clientStub.restore(); - }); - - describe('Publish', function() { - beforeEach(function() { - meta = sinon.stub().returns(); - clientStub = sinon.stub(client, 'client').returns({ - meta, - unpublish, - send, - ping, - }); - }); - - it('should deploy built html files', async function() { - const dir = path.resolve(process.cwd(), 'test/fixtures'); - const f = `${dir}/index.html`; - getFilesStub - .withArgs(dir) - .returns([f]); - - await deploy({dir}); - expect(send.calledOnceWith(f)).to.be.true; - }); - - it('should deploy built css files', async function() { - const dir = path.resolve(process.cwd(), 'test/fixtures'); - const f = `${dir}/test.css`; - getFilesStub - .withArgs(dir) - .returns([f]); - - await deploy({dir}); - expect(send.calledOnceWith(f)).to.be.true; - }); - }); - - describe('Unpublish', function() { - beforeEach(function() { - meta = sinon.stub().returns({ - total_pages: 1, - total_records: 3, - records: [ - {url: 'test/index.html'}, - ], - }); - clientStub = sinon.stub(client, 'client').returns({ - meta, - unpublish, - send, - ping, - }); - }); - - it('should unpublish missing files', async function() { - const dir = path.resolve(process.cwd(), 'test/fixtures'); - getFilesStub.withArgs(dir).returns([ - `${dir}/index.html`, - ]); - - await deploy({dir}); - expect(unpublish.calledOnceWith('test/index.html')).to.be.true; - }); - }); - - describe('Local meta', function() { - let enabled; - let store; - let save; - beforeEach(function() { - meta = sinon.stub().returns(); - send.returns({ - 'url': '/test.html', - 'md5': 'test', - }); - clientStub = sinon.stub(client, 'client').returns({ - meta, - unpublish, - send, - ping, - }); - enabled = sinon.stub(revisions, 'enabled'); - store = sinon.stub(revisions, 'store'); - save = sinon.stub(revisions, 'save'); - }); - - afterEach(function() { - enabled.restore(); - store.restore(); - save.restore(); - }); - - it('should build the local md5 cache', async function() { - const dir = path.resolve(process.cwd(), 'test/fixtures'); - getFilesStub.withArgs(dir).returns([ - `${dir}/index.html`, - ]); - await deploy({ - 'dir': dir, - 'revision-log': '/tmp/test-log.json', - 'r': '/tmp/test-log.json', - }); - expect(enabled.firstCall.calledWith(true)).to.be.true; - expect(store.firstCall.calledWith({ - 'url': '/test.html', - 'md5': 'test', - })).to.be.true; - expect(save.calledOnce).to.be.true; - }); - }); -}); diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index da20557..0000000 --- a/test/config.test.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Configuration file testing. - */ - -const config = require('../src/config.js'); -const fs = require('fs'); - -describe('Config', function() { - let chai, sinon, assert, expect - - beforeEach(async() => { - chai = await import('chai') - sinon = (await import('sinon')).default - assert = chai.assert - expect = chai.expect - }) - - afterEach(async () => { - sinon.restore(); - }) - - describe('defaults', function() { - it('should have a default endpoint', function() { - assert.equal(config.get('endpoint'), 'https://api.quantcdn.io/v1'); - }); - it('should have a default directory', function() { - assert.equal(config.get('dir'), 'build'); - }); - }); - describe('set()', function() { - it('should update client id', function() { - const result = {clientid: 'test'}; - config.set(result); - assert.equal(config.get('clientid'), 'test'); - }); - it('should set token', function() { - const result = {token: 'test'}; - config.set(result); - assert.equal(config.get('token'), 'test'); - }); - }); - describe('save()', function() { - let writeFileSync; - beforeEach(function() { - sinon.restore(); - writeFileSync = sinon.stub(fs, 'writeFileSync').returns({}); - config.set({ - dir: 'build', - endpoint: 'http://quantcdn.io', - clientid: null, - token: null, - project: null, - }); - }); - afterEach(function() { - writeFileSync.restore(); - }); - it('should save the state', function() { - config.save(); - const data = JSON.stringify({ - dir: 'build', - endpoint: 'http://quantcdn.io', - clientid: null, - project: null, - token: null, - bearer: null, - }, null, 2); - expect(writeFileSync.calledOnceWith('./quant.json', data)).to.be.true; - }); - it('should save updated state', function() { - const results = {clientid: 'test'}; - config.set(results); - config.save(); - const data = JSON.stringify({ - dir: 'build', - endpoint: 'http://quantcdn.io', - clientid: 'test', - project: null, - token: null, - bearer: null, - }, null, 2); - expect(writeFileSync.calledOnceWith('./quant.json', data)).to.be.true; - }); - it('should save to given directory', function() { - config.save('/tmp'); - const data = JSON.stringify({ - dir: 'build', - endpoint: 'http://quantcdn.io', - clientid: null, - project: null, - token: null, - bearer: null, - }, null, 2); - expect(writeFileSync.calledOnceWith('/tmp/quant.json', data)).to.be.true; - }); - }); - describe('load()', function() { - let readFileSync; - - afterEach(function() { - readFileSync.restore(); - }); - it('should load from a given directory', function() { - readFileSync = sinon.stub(fs, 'readFileSync').returns( - JSON.stringify({ - dir: '.', - endpoint: 'http://api.quantcdn.io', - clientid: 'test', - token: 'test', - project: 'test', - }), - ); - const status = config.load(`/tmp`); - expect(readFileSync.calledOnceWith(`/tmp/quant.json`)).to.be.true; - assert.equal(status, true); - assert.equal(config.get('clientid'), 'test'); - assert.equal(config.get('token'), 'test'); - assert.equal(config.get('project'), 'test'); - }); - it('should return FALSE if file is not found', function() { - readFileSync = sinon.stub(fs, 'readFileSync').throwsException(); - const status = config.load('/tmp'); - expect(readFileSync.calledOnceWith('/tmp/quant.json')).to.be.true; - assert.equal(status, false); - }); - }); -}); diff --git a/test/crawl/detectors/images.test.js b/test/crawl/detectors/images.test.js deleted file mode 100644 index ac44956..0000000 --- a/test/crawl/detectors/images.test.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Test the image detection. - */ - -const detector = require('../../../src/crawl/detectors/images'); -const getFiles = require("../../../src/helper/getFiles"); - -const fs = require('fs'); - -const cssString = fs.readFileSync('test/fixtures/test.css').toString(); -const htmlString = fs.readFileSync("test/fixtures/index.html").toString(); - -describe('crawl:detectors:images', function() { - let chai, cap, expect, assert; - - beforeEach(async () => { - chai = await import('chai'); - cap = (await import('chai-as-promised')).default; - chai.use(cap); - assert = chai.assert; - expect = chai.expect; - }) - - describe('applies', () => { - it('should apply to HTML', () => { - const res = {}; - res.headers = {}; - res.headers['content-type'] = 'text/html'; - expect(detector.applies(res)).to.be.true; - }); - - it('should apply to CSS', () => { - const res = {}; - res.headers = {}; - res.headers['content-type'] = 'text/css'; - expect(detector.applies(res)).to.be.true; - }); - - it('should not apply to other mime types', () => { - const res = {}; - res.headers = {}; - const mimes = [ - 'text/plain', - 'application/json', - 'text/javascript', - 'imagea/apng', - 'image/gif', - 'audio/wave', - 'multipart/form-data', - ]; - - mimes.map((i) => { - res.headers['content-type'] = i; - expect(detector.applies(res)).to.be.false; - }); - }); - }); - - describe('handler', () => { - it('should add host and proto', function() { - const items = detector.handler(cssString, 'test.com'); - const expected = 'https://test.com/nala.jpg'; - expect(items).to.include(expected); - }); - - it('should allow custom proto', function() { - const items = detector.handler(cssString, 'test.com', 'http'); - const expected = 'http://test.com/nala.jpg'; - expect(items).to.include(expected); - }); - it('should return empty array', function() { - const items = detector.handler(''); - const expected = []; - expect(items).to.eql(expected); - }); - - describe('CSS String', function() { - it('should find background images', function() { - const items = detector.handler(cssString); - const expected = '/nala.jpg'; - expect(items).to.include(expected); - }); - }); - - describe('HTML String', function() { - it('should find background images', function() { - const items = detector.handler(htmlString); - const expected = '/nala.jpg'; - expect(items).to.include(expected); - }); - it('should find data-src attributes', function() { - const items = detector.handler(htmlString); - const expected = '/files/assets/test.jpg'; - expect(items).to.include(expected); - }); - - it('should find data-src-retina attributes', function() { - const items = detector.handler(htmlString); - const expected = '/files/assets/test-retina.jpg'; - expect(items).to.include(expected); - }); - - it('should append host and proto', function() { - const items = detector.handler(htmlString, 'test.com', 'https'); - const expected = [ - 'https://test.com/nala.jpg', - 'https://test.com/files/assets/test-retina.jpg', - 'https://test.com/files/assets/test.jpg', - ]; - expect(items).to.eql(expected); - }); - }); - }); -}); diff --git a/test/crawl/detectors/responsiveImg.test.js b/test/crawl/detectors/responsiveImg.test.js deleted file mode 100644 index 1cd3442..0000000 --- a/test/crawl/detectors/responsiveImg.test.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Test the image detection. - */ - -const detector = require("../../../src/crawl/detectors/responsiveImg"); -const fs = require('fs'); - -const htmlString = fs.readFileSync("test/fixtures/responsive-images.html").toString(); - -describe('crawl:detectors:responsiveImg', function() { - let chai, cap, expect; - - beforeEach(async () => { - chai = await import('chai'); - cap = (await import('chai-as-promised')).default; - chai.use(cap); - expect = chai.expect; - }) - - describe('applies', () => { - it('should apply to HTML', () => { - const res = {}; - res.headers = {}; - res.headers['content-type'] = 'text/html'; - expect(detector.applies(res)).to.be.true; - }); - - it('should not apply to other mime types', () => { - const res = {}; - res.headers = {}; - const mimes = [ - 'text/css', - 'text/plain', - 'application/json', - 'text/javascript', - 'imagea/apng', - 'image/gif', - 'audio/wave', - 'multipart/form-data', - ]; - - mimes.map((i) => { - res.headers['content-type'] = i; - expect(detector.applies(res)).to.be.false; - }); - }); - }); - - describe('handler', () => { - it('should find picture elements', function() { - const images = detector.handler(htmlString, ''); - expect(images).to.include('elva-480w-close-portrait.jpg'); - expect(images).to.include('elva-800w.jpg'); - }); - - it('should find srcset attributes', function() { - const images = detector.handler(htmlString, ''); - expect(images).to.include('elva-fairy-320w.jpg'); - expect(images).to.include('elva-fairy-480w.jpg'); - expect(images).to.include('elva-fairy-640w.jpg'); - }); - - it('should include the host', function() { - const host = 'test.com.au'; - const images = detector.handler(htmlString, host); - expect(images).to.include(`https://${host}/elva-fairy-320w.jpg`); - expect(images).to.include(`https://${host}/elva-fairy-480w.jpg`); - expect(images).to.include(`https://${host}/elva-fairy-640w.jpg`); - }); - - it('should not duplicate the host', function() { - const host = 'test.com.au'; - const images = detector.handler(htmlString, host); - expect(images).to.not.include(`https://${host}/https:/${host}/hotlinked-image.jpg`); - expect(images).to.include(`https://${host}/hotlinked-image.jpg`); - expect(images).to.include(`https://${host}/hotlinked-image-480w.jpg`); - expect(images).to.include(`https://${host}/hotlinked-image-640w.jpg`); - }); - }); -}); diff --git a/test/crawl/filters/relativeDomains.test.js b/test/crawl/filters/relativeDomains.test.js deleted file mode 100644 index 5911f2d..0000000 --- a/test/crawl/filters/relativeDomains.test.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Test the redirect determination logic. - */ -const {relativeDomains} = require('../../../src/crawl/filters'); - -describe('crawl:filters:relativeDomains', function() { - let chai, sinon; - - const opts = {host: 'localhost', port: 3000}; - - // Disable console log for neater test output. - before(async () => { - chai = await import('chai'); - sinon = await import('sinon'); - sinon.stub(console, 'log'); - }); - after(() => sinon.restore()); - - it('should define an option', () => { - chai.expect(relativeDomains.option).to.eql('rewrite'); - }); - - it('should replace host name', () => { - let string = 'https://localhost/test/link'; - string = relativeDomains.handler(string, opts); - - chai.expect(string).to.eql('/test/link'); - }); - - it('should replace ports', () => { - let string = 'https://localhost:3000/test/link'; - string = relativeDomains.handler(string, opts); - - chai.expect(string).to.eql('/test/link'); - }); - - it('should not replace remote domains', () => { - let string = 'https://google.com/test/link'; - string = relativeDomains.handler(string, opts); - - chai.expect(string).to.eql('https://google.com/test/link'); - }); - - it('should replace all occurrences', () => { - let string = 'test link' + - '

Some other thing

' + - 'Google' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - 'https://localhost:3000/test-other-link'; - - string = relativeDomains.handler(string, opts); - - chai.expect(string).to.not.include('https://localhost:3000'); - chai.expect(string).to.include('https://google.com/deeplink'); - chai.expect(string).to.include('/test/link'); - chai.expect(string).to.include('/test-other-link'); - }); - - it('should replace extra domains', () => { - let string = 'test link' + - '

Some other thing

' + - 'Extra' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - 'https://localhost:3000/test-other-link'; - - string = relativeDomains.handler(string, opts, {'extra-domains': 'content.localhost'}); - - chai.expect(string).to.not.include('https://localhost:3000'); - chai.expect(string).to.not.include('https://content.localhost'); - chai.expect(string).to.include('/test/link'); - chai.expect(string).to.include('/path/to/somewhere'); - }); - it('should replace multiple extra domains', () => { - let string = 'test link' + - '

Some other thing

' + - 'Extra' + - 'Extra' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - '

Some other thing

' + - 'https://localhost:3000/test-other-link'; - - string = relativeDomains.handler(string, opts, { - 'extra-domains': 'content.localhost, content.main.com.au', - }); - - chai.expect(string).to.not.include('https://localhost:3000'); - chai.expect(string).to.not.include('https://content.localhost'); - chai.expect(string).to.not.include('https://content.main.com.au'); - chai.expect(string).to.include('/test/link'); - chai.expect(string).to.include('/path/to/somewhere'); - }); -}); diff --git a/test/crawl/redirectcb.test.js b/test/crawl/redirectcb.test.js deleted file mode 100644 index 7031183..0000000 --- a/test/crawl/redirectcb.test.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Test the redirect determination logic. - */ -const {redirectHandler} = require('../../src/crawl/callbacks'); - -// Stubs -const client = require('../../src/quant-client'); - -describe('crawl::redirectHandler', function() { - let quant; - let chai, sinon, sinonChai; - - // Disable console log for neater test output. - before(async () => { - chai = await import('chai') - sinon = await import('sinon') - sinonChai = (await import('sinon-chai')).default; - - chai.should() - chai.use(sinonChai) - - sinon.stub(console, 'log'); - }); - after(() => sinon.restore()); - - beforeEach(() => { - quant = {redirect: sinon.spy()}; - }); - - afterEach(() => { - quant = null; - }); - - it('should not redirect same paths', () => { - const path = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - const dest = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.not.have.been.called; - }); - - it('should inherit HTTP code from response', () => { - const path = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - const dest = {path: '/dest', host: 'test.com', url: 'http://test.com/dest', stateData: {code: 301}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledOnceWith('/test', '/dest', 'quant-cli', 301); - }); - - it('should default to 301 codes', () => { - const path = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - const dest = {path: '/dest', host: 'test.com', url: 'http://test.com/dest', stateData: {}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledOnceWith('/test', '/dest', 'quant-cli', 301); - }); - - it('should be able to redirect bare /', () => { - const path = {path: '/', host: 'test.com', url: 'http://test.com/', stateData: {code: 302}}; - const dest = {path: '/Home', host: 'test.com', url: 'http://test.com/Home', stateData: {code: 302}}; - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledOnceWith('/', '/Home', 'quant-cli', 302); - }); - - describe('internal', () => { - - it('should redirect paths ending with slashes', () => { - const path = {path: '/test/', host: 'test.com', url: 'http://test.com/test/', stateData: {code: 302}}; - const dest = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledWith('/test/', '/test', 'quant-cli', 302); - quant.redirect.should.have.been.calledOnce; - }); - - it('should redirect differing paths', () => { - const path = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - const dest = {path: '/test-destination', host: 'test.com', url: 'http://test.com/test-destination', stateData: {code: 302}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledWith('/test', '/test-destination', 'quant-cli', 302); - quant.redirect.should.have.been.calledOnce; - }); - }); - - describe('external', () => { - it('should redirect remote origins', () => { - const path = {path: '/test', host: 'test.com', url: 'http://test.com/test', stateData: {code: 302}}; - const dest = {path: '/test-destination', host: 'google.com', url: 'http://google.com/test-destination', stateData: {code: 302}}; - - redirectHandler(quant, path, dest); - quant.redirect.should.have.been.calledWith('/test', 'http://google.com/test-destination', 'quant-cli', 302); - quant.redirect.should.have.been.calledOnce; - }); - }); -}); diff --git a/test/fixtures/index.html b/test/fixtures/index.html deleted file mode 100644 index 779cde3..0000000 --- a/test/fixtures/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Enter a title, displayed at the top of the window. - - - - -

Enter the main heading, usually the same as the title.

-

Be bold in stating your key points. Put them in a list:

-
    -
  • The first item in your list
  • -
  • The second item; italicize key words
  • -
-

Improve your image by including an image.

-

A Great HTML Resource

-

Add a link to your favorite Web site. - Break up your page with a horizontal rule or two.

-
-

Finally, link to another page in your own Web site.

-
-
-
- -

© Wiley Publishing, 2011

- - - diff --git a/test/fixtures/nala.jpg b/test/fixtures/nala.jpg deleted file mode 100644 index c17614a..0000000 Binary files a/test/fixtures/nala.jpg and /dev/null differ diff --git a/test/fixtures/responsive-images.html b/test/fixtures/responsive-images.html deleted file mode 100644 index 2bb811e..0000000 --- a/test/fixtures/responsive-images.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Enter a title, displayed at the top of the window. - - - - - Elva dressed as a fairy - - Elva dressed as a fairy - - Hotlinked image - - - - - Chris standing up holding his daughter Elva - - - - - - - - - diff --git a/test/fixtures/sample/nala.jpg b/test/fixtures/sample/nala.jpg deleted file mode 100644 index c17614a..0000000 Binary files a/test/fixtures/sample/nala.jpg and /dev/null differ diff --git a/test/fixtures/some-file-path.html b/test/fixtures/some-file-path.html deleted file mode 100644 index f448a0a..0000000 --- a/test/fixtures/some-file-path.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Enter a title, displayed at the top of the window. - - - - -

Enter the main heading, usually the same as the title.

-

Be bold in stating your key points. Put them in a list:

-
    -
  • The first item in your list
  • -
  • The second item; italicize key words
  • -
-

Improve your image by including an image.

-

A Great HTML Resource

-

Add a link to your favorite Web site. - Break up your page with a horizontal rule or two.

-
-

Finally, link to another page in your own Web site.

- -

© Wiley Publishing, 2011

- - - diff --git a/test/fixtures/test.css b/test/fixtures/test.css deleted file mode 100644 index 6840e57..0000000 --- a/test/fixtures/test.css +++ /dev/null @@ -1,3 +0,0 @@ -#nala-bg { - background-image: url('/nala.jpg'); -} diff --git a/test/fixtures/test.js b/test/fixtures/test.js deleted file mode 100644 index e69de29..0000000 diff --git a/test/helper/getFiles.test.js b/test/helper/getFiles.test.js deleted file mode 100644 index ca3469d..0000000 --- a/test/helper/getFiles.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Test the get files helper. - */ -const getFiles = require('../../src/helper/getFiles'); - -describe('helpers::getFiles', function() { - let chai, cap, assert, expect; - - beforeEach(async () => { - chai = await import('chai'); - cap = (await import('chai-as-promised')).default; - - chai.use(cap); - assert = chai.assert; - expect = chai.expect; - }); - - it('should return a promise', function() { - return getFiles('test/fixtures') - .then((files) => { - assert.isTrue(true); - }); - }); - - it('should return contents of a given directory', async function() { - const files = await getFiles('test/fixtures'); - - const expected = [ - `${process.cwd()}/test/fixtures/index.html`, - `${process.cwd()}/test/fixtures/nala.jpg`, - `${process.cwd()}/test/fixtures/responsive-images.html`, - `${process.cwd()}/test/fixtures/sample/nala.jpg`, - `${process.cwd()}/test/fixtures/some-file-path.html`, - `${process.cwd()}/test/fixtures/test.css`, - `${process.cwd()}/test/fixtures/test.js`, - ]; - - expect(files).to.eql(expected); - }); - - it('should handle non-existent directories', async function() { - return expect(getFiles('/not/here')).to.eventually.be.rejectedWith(Error); - }); -}); diff --git a/test/helper/normalizePath.test.js b/test/helper/normalizePath.test.js deleted file mode 100644 index d8b1634..0000000 --- a/test/helper/normalizePath.test.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Test the path noramlizer. - */ -const normalizePaths = require('../../src/helper/normalizePaths'); -const path = require('path'); - -describe('helpers::normalizePaths', () => { - let chai, cap, expect; - - beforeEach(async () => { - chai = await import('chai'); - cap = (await import('chai-as-promised')).default; - - chai.use(cap); - expect = chai.expect; - }); - - it('should convert system paths', () => { - // Bit of a strange test cause tests always run in poisx envs. - const localPath = '/path/to/file'; - expect(normalizePaths(localPath)).to.eql('/path/to/file'); - }); - - it('should convert win paths', () => { - // Bit of a strange test cause tests always run in poisx envs. - const localPath = '\\path\\to\\file'; - expect(normalizePaths(localPath, path.win32.sep)).to.eql('/path/to/file'); - }); - -}); diff --git a/test/helper/quant-url.js b/test/helper/quant-url.js deleted file mode 100644 index 9e7456f..0000000 --- a/test/helper/quant-url.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Test the URL helpers. - */ -const url = require('../../src/helper/quant-url'); - -describe('helpers::url', () => { - let chai, cap, expect; - - beforeEach(async () => { - chai = await import('chai'); - cap = (await import('chai-as-promised')).default; - chai.use(cap); - expect = chai.expect; - }); - - describe('prepare', () => { - it('should remove index.html', () => { - expect(url.prepare('/test/index.html')).to.eql('/test'); - }); - it('should keep html files', () => { - expect(url.prepare('/test.html')).to.eql('/test.html'); - }); - it('should return / for only index', () => { - expect(url.prepare('/index.html')).to.eql('/'); - expect(url.prepare('index.html')).to.eql('/'); - }); - it('should respect nested structures', () => { - expect(url.prepare('/nested/directory/index.html')).to.eql('/nested/directory'); - }); - it('should handle missing leading /', () => { - expect(url.prepare('test/index.html')).to.eql('/test'); - }); - it('should not partial match index.html', () => { - expect(url.prepare('/old-index.html')).to.eql('/old-index.html'); - }); - it('should normalize case', () => { - expect(url.prepare('/StRaNgE-CaSE/index.HTML')).to.eql('/strange-case'); - }); - }); -}); diff --git a/tests/.eslintrc.js b/tests/.eslintrc.js new file mode 100644 index 0000000..658df8f --- /dev/null +++ b/tests/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + files: ['**/*.{js,mjs}'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + process: 'readonly', + require: 'readonly', + module: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + console: 'readonly', + // Mocha globals + describe: 'readonly', + it: 'readonly', + before: 'readonly', + after: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly' + } + }, + rules: { + 'no-console': 'off', + 'no-unused-vars': ['error', { + 'argsIgnorePattern': '^_', + 'varsIgnorePattern': '^_' + }] + } +}; \ No newline at end of file diff --git a/tests/helpers/mockAxios.js b/tests/helpers/mockAxios.js new file mode 100644 index 0000000..666b608 --- /dev/null +++ b/tests/helpers/mockAxios.js @@ -0,0 +1,17 @@ +import axios from 'axios'; +import sinon from 'sinon'; + +export default function mockAxiosForDeploy() { + // Mock the axios instance methods + const mockInstance = { + request: sinon.stub().resolves({ data: { success: true } }), + get: sinon.stub().resolves({ data: { project: 'test-project' } }), + post: sinon.stub().resolves({ data: { success: true } }), + patch: sinon.stub().resolves({ data: { success: true } }) + }; + + // Mock axios.create to return our mock instance + sinon.stub(axios, 'create').returns(mockInstance); + + return mockInstance; +} \ No newline at end of file diff --git a/tests/helpers/mockAxios.mjs b/tests/helpers/mockAxios.mjs new file mode 100644 index 0000000..666b608 --- /dev/null +++ b/tests/helpers/mockAxios.mjs @@ -0,0 +1,17 @@ +import axios from 'axios'; +import sinon from 'sinon'; + +export default function mockAxiosForDeploy() { + // Mock the axios instance methods + const mockInstance = { + request: sinon.stub().resolves({ data: { success: true } }), + get: sinon.stub().resolves({ data: { project: 'test-project' } }), + post: sinon.stub().resolves({ data: { success: true } }), + patch: sinon.stub().resolves({ data: { success: true } }) + }; + + // Mock axios.create to return our mock instance + sinon.stub(axios, 'create').returns(mockInstance); + + return mockInstance; +} \ No newline at end of file diff --git a/tests/mocks/quant-client.mjs b/tests/mocks/quant-client.mjs new file mode 100644 index 0000000..a595bf7 --- /dev/null +++ b/tests/mocks/quant-client.mjs @@ -0,0 +1,199 @@ +/** + * Mock Quant client for testing. + */ +export default function (_config) { + const history = { + get: [], + post: [], + patch: [], + delete: [] + }; + + const client = { + _history: history, + + file: async function(filePath, location) { + history.post.push({ + url: '/file', + headers: { + 'Quant-File-Url': location + }, + data: filePath + }); + return { success: true }; + }, + + markup: async function(filePath, location) { + history.post.push({ + url: '/markup', + headers: { + 'Quant-File-Url': location + }, + data: filePath + }); + return { success: true }; + }, + + send: async function(filePath, location, force = false, findAttachments = false, skipPurge = false, enableIndexHtml = false) { + history.post.push({ + url: '/send', + headers: { + 'Quant-File-Url': location, + 'Force-Deploy': force, + 'Find-Attachments': findAttachments, + 'Skip-Purge': skipPurge, + 'Enable-Index-Html': enableIndexHtml + }, + data: filePath + }); + return { + url: location, + md5: 'test-md5', + success: true + }; + }, + + meta: async function(unfold = false, exclude = true, extend = {}) { + history.get.push({ + url: '/meta', + params: { unfold, exclude, ...extend } + }); + return { + records: [ + { url: '/test.html', type: 'file' }, + { url: '/css/style.css', type: 'file' }, + { url: '/images/logo.png', type: 'file' } + ], + total_pages: 1, + total_records: 3 + }; + }, + + ping: async function() { + return { project: 'test-project' }; + }, + + unpublish: async function(url) { + history.post.push({ + url: '/unpublish', + headers: { + 'Quant-Url': url + } + }); + return { success: true }; + }, + + // Additional methods from real client + batchMeta: async function(urls) { + history.post.push({ + url: '/batch-meta', + data: { urls } + }); + return { success: true }; + }, + + purge: async function(url, cacheKeys, options = {}) { + history.post.push({ + url: '/purge', + headers: { + 'Quant-Url': url, + 'Cache-Keys': cacheKeys, + 'Soft-Purge': options.softPurge + } + }); + return { success: true }; + }, + + searchIndex: async function(data) { + history.post.push({ + url: '/search', + data + }); + return { success: true }; + }, + + searchRemove: async function(url) { + history.delete.push({ + url: '/search', + headers: { 'Quant-Url': url } + }); + return { success: true }; + }, + + redirect: async function(from, to, _headers = null, status = 302) { + history.post.push({ + url: '/redirect', + headers: { + 'Quant-From-Url': from, + 'Quant-To-Url': to, + 'Quant-Status': status + } + }); + return { success: true, uuid: 'mock-uuid-123' }; + }, + + delete: async function(url) { + history.delete.push({ + url: '/delete', + headers: { + 'Quant-Url': url + } + }); + return { + meta: [{ + deleted: true + }] + }; + }, + + edgeFunction: async function(file, description, uuid = null) { + history.post.push({ + url: '/functions', + headers: { + 'Content-Type': 'application/json' + }, + data: { + content: file, + desc: description, + uuid: uuid, + published: true + } + }); + return { success: true, uuid: uuid || 'new-uuid' }; + }, + + edgeFilter: async function(file, description, uuid = null) { + history.post.push({ + url: '/functions/filter', + headers: { + 'Content-Type': 'application/json' + }, + data: { + content: file, + desc: description, + uuid: uuid, + published: true + } + }); + return { success: true, uuid: uuid || 'new-uuid' }; + }, + + edgeAuth: async function(file, description, uuid = null) { + history.post.push({ + url: '/functions/auth', + headers: { + 'Content-Type': 'application/json' + }, + data: { + content: file, + desc: description, + uuid: uuid, + published: true + } + }); + return { success: true, uuid: uuid || 'new-uuid' }; + } + }; + + return client; +} \ No newline at end of file diff --git a/tests/setup.mjs b/tests/setup.mjs new file mode 100644 index 0000000..943b4c3 --- /dev/null +++ b/tests/setup.mjs @@ -0,0 +1,11 @@ +import { expect, assert, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +// Add sinon-chai assertions +use(sinonChai); + +// Global test helpers +global.expect = expect; +global.assert = assert; +global.sinon = sinon; \ No newline at end of file diff --git a/tests/unit/commands/delete.test.mjs b/tests/unit/commands/delete.test.mjs new file mode 100644 index 0000000..48196c5 --- /dev/null +++ b/tests/unit/commands/delete.test.mjs @@ -0,0 +1,164 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const deleteCommand = (await import('../../../src/commands/delete.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Delete Command', () => { + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + })[key], + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should delete a path with force flag', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + path: '/about', + force: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deleteCommand.handler.call(context, args); + expect(result).to.include('Successfully removed [/about]'); + expect(mockClientInstance._history.delete.length).to.equal(1); + const [call] = mockClientInstance._history.delete; + expect(call.headers['Quant-Url']).to.equal('/about'); + }); + + it('should handle missing args', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + try { + await deleteCommand.handler.call(context, null); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle config fromArgs failure', async () => { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + config: { + ...mockConfig, + fromArgs: async () => false + } + }; + + const args = { + path: '/about', + force: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await deleteCommand.handler.call(context, args); + expect.fail('Process exited with code 1'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + + it('should handle already deleted paths', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.delete = async () => ({ + meta: [{ + deleted_timestamp: '2024-01-01' + }] + }); + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + path: '/already-deleted', + force: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deleteCommand.handler.call(context, args); + expect(result).to.include('was already deleted'); + }); + + it('should handle general errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.delete = async () => { + throw new Error('Failed to delete'); + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + path: '/about', + force: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await deleteCommand.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Cannot delete path'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/deploy.test.mjs b/tests/unit/commands/deploy.test.mjs new file mode 100644 index 0000000..6bbdf75 --- /dev/null +++ b/tests/unit/commands/deploy.test.mjs @@ -0,0 +1,298 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const deploy = (await import('../../../src/commands/deploy.js')).default; +const config = (await import('../../../src/config.js')).default; +const getFiles = (await import('../../../src/helper/getFiles.js')).default; +const md5File = (await import('md5-file')).default; + +describe('Deploy Command', () => { + let mockFs; + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token', + dir: 'build' + }[key]), + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Mock file system + mockFs = { + readFileSync: sinon.stub().returns('test content'), + existsSync: sinon.stub().returns(true), + createReadStream: sinon.stub().returns('test content'), + statSync: sinon.stub().returns({ isFile: () => true }), + readdirSync: sinon.stub().returns(['index.html', 'styles.css', 'images/logo.png']) + }; + + // Mock getFiles to return test files + sinon.stub(getFiles, 'getFiles').returns([ + 'build/index.html', + 'build/styles.css', + 'build/images/logo.png' + ]); + + // Mock md5File + sinon.stub(md5File, 'sync').returns('test-md5-hash'); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should deploy files successfully', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + expect(mockClientInstance._history.post.length).to.be.greaterThan(0); + }); + + it('should handle force flag', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + force: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle attachments flag', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + attachments: true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle skip-unpublish flag', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + 'skip-unpublish': true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle non-existent directory', async () => { + mockFs.existsSync.returns(false); + mockFs.readdirSync.returns([]); + + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'nonexistent', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle empty directory', async () => { + mockFs.readdirSync.returns([]); + + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle MD5 match errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.send = async () => { + throw { + response: { + data: { + errorMsg: 'MD5 already matches existing file.' + } + } + }; + }; + + const context = { + fs: mockFs, + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + dir: 'build', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle config fromArgs failure', async () => { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + fs: mockFs, + config: { + ...mockConfig, + fromArgs: async () => false, + get: () => null + }, + client: () => mockClientInstance + }; + + const args = { + dir: 'build', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await deploy.handler.call(context, args); + expect.fail('Process exited with code 1'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + + it('should handle unpublish process', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => ({ + ...mockClientInstance, + meta: async () => ({ + records: [ + { url: '/old-file.html', type: 'file' } + ] + }) + }) + }; + + const args = { + dir: 'build', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + + it('should handle skip-unpublish-regex', async () => { + const context = { + fs: mockFs, + config: mockConfig, + client: () => ({ + ...mockClientInstance, + meta: async () => ({ + records: [ + { url: '/skip-me.html', type: 'file' } + ] + }) + }) + }; + + const args = { + dir: 'build', + 'skip-unpublish-regex': 'skip-me', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await deploy.handler.call(context, args); + expect(result).to.equal('Deployment completed successfully'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/file.test.mjs b/tests/unit/commands/file.test.mjs new file mode 100644 index 0000000..b3c75bb --- /dev/null +++ b/tests/unit/commands/file.test.mjs @@ -0,0 +1,196 @@ +import { expect } from 'chai'; +import _fs from 'fs'; +import _path from 'path'; +import sinon from 'sinon'; +import mockClient from '../../mocks/quant-client.mjs'; + +const file = (await import('../../../src/commands/file.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('File Command', () => { + let mockFs; + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token', + dir: 'build' + }[key]), + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Mock file system + mockFs = { + readFileSync: sinon.stub().returns('test content'), + existsSync: sinon.stub().returns(true), + createReadStream: sinon.stub().returns('test content'), + statSync: sinon.stub().returns({ isFile: () => true }) + }; + }); + + describe('handler', () => { + it('should deploy a single file', async function() { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'test.css', + location: '/css/test.css', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await file.handler.call(context, args); + expect(result).to.equal('Added [test.css]'); + expect(mockClientInstance._history.post.length).to.equal(1); + + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-File-Url']).to.equal('/css/test.css'); + }); + + it('should prompt for missing file and location', async function() { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => ({ + file: 'prompted.css', + location: '/css/prompted.css' + }) + }; + + const args = { + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await file.handler.call(context, args); + expect(result).to.equal('Added [prompted.css]'); + + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-File-Url']).to.equal('/css/prompted.css'); + }); + + it('should handle cancelled prompt', async function() { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + const args = { + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await file.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle file already exists error', async function() { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.file = async () => { + throw new Error('File exists'); + }; + + const context = { + fs: mockFs, + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + file: 'test.css', + location: '/css/test.css', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await file.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('exists at location'); + } + }); + + it('should handle missing args', async function() { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + try { + await file.handler.call(context, null); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle config fromArgs failure', async function() { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + fs: mockFs, + config: { + ...mockConfig, + fromArgs: async () => false + }, + promptArgs: async () => null, + client: () => { + throw new Error('Client factory should not be called when config fails'); + } + }; + + const args = { + file: 'test.css', + location: '/css/test.css', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await file.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/functions.test.mjs b/tests/unit/commands/functions.test.mjs new file mode 100644 index 0000000..91561e3 --- /dev/null +++ b/tests/unit/commands/functions.test.mjs @@ -0,0 +1,203 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const functions = (await import('../../../src/commands/functions.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Functions Command', () => { + let mockConfig; + let mockClientInstance; + let readFileSync; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + })[key], + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Mock fs.readFileSync + readFileSync = sinon.stub(_fs, 'readFileSync'); + sinon.stub(_fs, 'existsSync').returns(true); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should deploy auth functions', async () => { + const mockJson = [{ + type: 'auth', + path: './auth.js', + description: 'Test auth function', + uuid: 'test-uuid' + }]; + + readFileSync.returns(JSON.stringify(mockJson)); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'functions.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await functions.handler.call(context, args); + expect(result).to.include('All functions processed successfully'); + expect(mockClientInstance._history.post.length).to.equal(1); + }); + + it('should deploy filter functions', async () => { + const mockJson = [{ + type: 'filter', + path: './filter.js', + description: 'Test filter function' + }]; + + readFileSync.returns(JSON.stringify(mockJson)); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'functions.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await functions.handler.call(context, args); + expect(result).to.include('All functions processed successfully'); + expect(mockClientInstance._history.post.length).to.equal(1); + }); + + it('should deploy edge functions', async () => { + const mockJson = [{ + type: 'edge', + path: './edge.js', + description: 'Test edge function' + }]; + + readFileSync.returns(JSON.stringify(mockJson)); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'functions.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await functions.handler.call(context, args); + expect(result).to.include('All functions processed successfully'); + expect(mockClientInstance._history.post.length).to.equal(1); + }); + + it('should handle missing file', async () => { + readFileSync.throws(new Error('ENOENT: no such file or directory')); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'nonexistent.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await functions.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to read functions file'); + } + }); + + it('should handle invalid JSON', async () => { + readFileSync.returns('invalid json'); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'functions.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await functions.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to read functions file'); + } + }); + + it('should handle invalid function type', async () => { + const mockJson = [{ + type: 'invalid', + path: './invalid.js', + description: 'Invalid function' + }]; + + readFileSync.returns(JSON.stringify(mockJson)); + + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + file: 'functions.json', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await functions.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Invalid function type'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/page.test.mjs b/tests/unit/commands/page.test.mjs new file mode 100644 index 0000000..f25a220 --- /dev/null +++ b/tests/unit/commands/page.test.mjs @@ -0,0 +1,216 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const page = (await import('../../../src/commands/page.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Page Command', () => { + let mockFs; + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token', + enableIndexHtml: false + }[key]), + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance using the shared mock + mockClientInstance = mockClient(mockConfig); + + // Mock file system + mockFs = { + readFileSync: sinon.stub().returns('test content'), + existsSync: sinon.stub().returns(true), + createReadStream: sinon.stub().returns('test content'), + statSync: sinon.stub().returns({ isFile: () => true }) + }; + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should deploy a single page', async function() { + const context = { + fs: mockFs, + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + const args = { + file: 'about/index.html', + location: '/about/', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await page.handler.call(context, args); + expect(result).to.equal('Added [about/index.html]'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-File-Url']).to.equal('/about/'); + }); + + it('should handle enable-index-html setting', async function() { + // Override config for this test + const testConfig = { + ...mockConfig, + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token', + enableIndexHtml: true + }[key]) + }; + + const context = { + fs: mockFs, + config: testConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + const args = { + file: 'about/index.html', + location: '/about/', + 'enable-index-html': true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await page.handler.call(context, args); + expect(result).to.equal('Added [about/index.html]'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-File-Url']).to.equal('/about/index.html'); + }); + + it('should handle MD5 match errors', async function() { + // Create error client instance + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.markup = async () => { + throw { + response: { + data: { + errorMsg: 'MD5 already matches existing file.' + } + } + }; + }; + + const context = { + fs: mockFs, + config: mockConfig, + client: () => errorClientInstance, + promptArgs: async () => null + }; + + const args = { + file: 'about/index.html', + location: '/about/', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await page.handler.call(context, args); + expect(result).to.equal('Skipped [about/index.html] (content unchanged)'); + }); + + it('should handle errors', async function() { + // Create error client instance + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.markup = async () => { + throw new Error('Failed to add page'); + }; + + const context = { + fs: mockFs, + config: mockConfig, + client: () => errorClientInstance, + promptArgs: async () => null + }; + + const args = { + file: 'about/index.html', + location: '/about/', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await page.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to add page'); + } + }); + + it('should handle config fromArgs failure', async function() { + const exit = process.exit; + process.exit = (code) => { + console.log('Process.exit called with:', code); + process.exit = exit; // Restore immediately + throw new Error('Process exited with code 1'); + }; + + const context = { + fs: mockFs, + config: { + ...mockConfig, + fromArgs: async () => { + console.log('Mock config.fromArgs returning false'); + return false; + } + }, + client: () => { + throw new Error('Client factory should not be called when config fails'); + }, + promptArgs: async () => null + }; + + const args = { + file: 'about/index.html', + location: '/about/', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await page.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/purge.test.mjs b/tests/unit/commands/purge.test.mjs new file mode 100644 index 0000000..b729338 --- /dev/null +++ b/tests/unit/commands/purge.test.mjs @@ -0,0 +1,177 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const purge = (await import('../../../src/commands/purge.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Purge Command', () => { + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + })[key], + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should purge a single path', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await purge.handler.call(context, args); + expect(result).to.equal('Successfully purged /about'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-Url']).to.equal('/about'); + }); + + it('should handle soft purge option', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + path: '/about', + 'soft-purge': true, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await purge.handler.call(context, args); + expect(result).to.equal('Successfully purged /about'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Soft-Purge']).to.be.true; + }); + + it('should handle cache keys', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + 'cache-keys': 'key1 key2', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await purge.handler.call(context, args); + expect(result).to.equal('Successfully purged cache keys: key1 key2'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Cache-Keys']).to.equal('key1 key2'); + }); + + it('should handle missing args', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + try { + await purge.handler.call(context, null); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle config fromArgs failure', async () => { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + config: { + ...mockConfig, + fromArgs: async () => false + } + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await purge.handler.call(context, args); + expect.fail('Process exited with code 1'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + + it('should handle general errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.purge = async () => { + throw new Error('Failed to purge'); + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await purge.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to purge'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/redirect.test.mjs b/tests/unit/commands/redirect.test.mjs new file mode 100644 index 0000000..fd58f2e --- /dev/null +++ b/tests/unit/commands/redirect.test.mjs @@ -0,0 +1,185 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; + +const redirect = (await import('../../../src/commands/redirect.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Redirect Command', () => { + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + })[key], + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should create a redirect', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + from: '/old-path', + to: '/new-path', + status: 301, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await redirect.handler.call(context, args); + expect(result).to.equal('Created redirect from /old-path to /new-path (301)'); + expect(mockClientInstance._history.post.length).to.equal(1); + }); + + it('should handle MD5 match errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.redirect = async () => { + throw { + response: { + data: { + errorMsg: 'Published version already has md5: abc123' + } + } + }; + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + from: '/old-path', + to: '/new-path', + status: 301, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await redirect.handler.call(context, args); + expect(result).to.equal('Skipped redirect from /old-path to /new-path (already exists)'); + }); + + it('should handle missing args', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + try { + await redirect.handler.call(context, null); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle config fromArgs failure', async () => { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + config: { + ...mockConfig, + fromArgs: async () => false + } + }; + + const args = { + from: '/old-path', + to: '/new-path', + status: 301 + }; + + try { + await redirect.handler.call(context, args); + expect.fail('Process exited with code 1'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + + it('should handle general errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.redirect = async () => { + throw new Error('Failed to create redirect'); + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + from: '/old-path', + to: '/new-path', + status: 301, + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await redirect.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to create redirect'); + } + }); + + it('should use default status code if not provided', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + from: '/old-path', + to: '/new-path' + }; + + const result = await redirect.handler.call(context, args); + expect(result).to.equal('Created redirect from /old-path to /new-path (302)'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-Status']).to.equal(302); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/commands/unpublish.test.mjs b/tests/unit/commands/unpublish.test.mjs new file mode 100644 index 0000000..bc24ee4 --- /dev/null +++ b/tests/unit/commands/unpublish.test.mjs @@ -0,0 +1,166 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import _fs from 'fs'; +import _path from 'path'; +import mockClient from '../../mocks/quant-client.mjs'; +import stripAnsi from 'strip-ansi'; + +const unpublish = (await import('../../../src/commands/unpublish.js')).default; +const config = (await import('../../../src/config.js')).default; + +describe('Unpublish Command', () => { + let mockConfig; + let mockClientInstance; + + beforeEach(() => { + // Reset config state + config.set({}); + + // Create mock config + mockConfig = { + set: sinon.stub(), + get: (key) => ({ + endpoint: 'mock://api.quantcdn.io/v1', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + })[key], + fromArgs: sinon.stub().resolves(true), + save: sinon.stub() + }; + + // Create mock client instance + mockClientInstance = mockClient(mockConfig); + + // Stub console methods + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handler', () => { + it('should unpublish a path', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await unpublish.handler.call(context, args); + expect(stripAnsi(result)).to.equal('Successfully unpublished [/about]'); + expect(mockClientInstance._history.post.length).to.equal(1); + const [call] = mockClientInstance._history.post; + expect(call.headers['Quant-Url']).to.equal('/about'); + }); + + it('should handle missing args', async () => { + const context = { + config: mockConfig, + client: () => mockClientInstance, + promptArgs: async () => null + }; + + try { + await unpublish.handler.call(context, null); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Operation cancelled'); + } + }); + + it('should handle config fromArgs failure', async () => { + const exit = process.exit; + process.exit = (_code) => { + process.exit = exit; + throw new Error('Process exited with code 1'); + }; + + const context = { + config: { + ...mockConfig, + fromArgs: async () => false + } + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await unpublish.handler.call(context, args); + expect.fail('Process exited with code 1'); + } catch (err) { + expect(err.message).to.equal('Process exited with code 1'); + } finally { + process.exit = exit; + } + }); + + it('should handle already unpublished paths', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.unpublish = async () => { + throw { + response: { + status: 404, + data: { + errorMsg: 'not found' + } + } + }; + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + path: '/already-unpublished', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + const result = await unpublish.handler.call(context, args); + expect(stripAnsi(result)).to.equal('Path [/already-unpublished] does not exist or is already unpublished'); + }); + + it('should handle general errors', async () => { + const errorClientInstance = mockClient(mockConfig); + errorClientInstance.unpublish = async () => { + throw new Error('Failed to unpublish'); + }; + + const context = { + config: mockConfig, + client: () => errorClientInstance + }; + + const args = { + path: '/about', + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }; + + try { + await unpublish.handler.call(context, args); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.include('Failed to unpublish'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/config.test.mjs b/tests/unit/config.test.mjs new file mode 100644 index 0000000..98c4226 --- /dev/null +++ b/tests/unit/config.test.mjs @@ -0,0 +1,221 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import sinon from 'sinon'; + +const config = await import('../../src/config.js'); + +describe('Config', () => { + beforeEach(() => { + // Reset config to empty state + config.set({}); + + // Clean up any test config files + try { + fs.unlinkSync('quant.json'); + } catch (e) { + // Ignore if file doesn't exist + } + + // Clean up any environment variables + delete process.env.QUANT_CLIENT_ID; + delete process.env.QUANT_PROJECT; + delete process.env.QUANT_TOKEN; + delete process.env.QUANT_ENDPOINT; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('fromArgs', () => { + it('should set defaults when no args provided', async () => { + await config.fromArgs({}, true); + expect(config.get('endpoint')).to.equal('https://api.quantcdn.io/v1'); + expect(config.get('dir')).to.equal('build'); + }); + + it('should load from environment variables', async () => { + process.env.QUANT_CLIENT_ID = 'test-client'; + process.env.QUANT_PROJECT = 'test-project'; + process.env.QUANT_TOKEN = 'test-token'; + + await config.fromArgs({}, true); + + expect(config.get('clientid')).to.equal('test-client'); + expect(config.get('project')).to.equal('test-project'); + expect(config.get('token')).to.equal('test-token'); + }); + + it('should load from quant.json file', async () => { + const testConfig = { + clientid: 'json-client', + project: 'json-project', + token: 'json-token', + endpoint: 'https://custom.api.com/v1' + }; + + sinon.stub(fs, 'readFileSync').returns(JSON.stringify(testConfig)); + + await config.fromArgs({}, true); + + expect(config.get('clientid')).to.equal('json-client'); + expect(config.get('project')).to.equal('json-project'); + expect(config.get('token')).to.equal('json-token'); + expect(config.get('endpoint')).to.equal('https://custom.api.com/v1'); + }); + + it('should prioritize CLI args over environment variables', async () => { + // Set env vars + process.env.QUANT_CLIENT_ID = 'env-client'; + process.env.QUANT_PROJECT = 'env-project'; + + // Set CLI args + const args = { + clientid: 'cli-client', + project: 'cli-project' + }; + + await config.fromArgs(args, true); + + expect(config.get('clientid')).to.equal('cli-client'); + expect(config.get('project')).to.equal('cli-project'); + }); + + it('should prioritize CLI args over quant.json', async () => { + // Set quant.json + const fileConfig = { + clientid: 'json-client', + project: 'json-project' + }; + sinon.stub(fs, 'readFileSync').returns(JSON.stringify(fileConfig)); + + // Set CLI args + const args = { + clientid: 'cli-client', + project: 'cli-project' + }; + + await config.fromArgs(args, true); + + expect(config.get('clientid')).to.equal('cli-client'); + expect(config.get('project')).to.equal('cli-project'); + }); + + it('should prioritize environment variables over quant.json', async () => { + // Set quant.json + const fileConfig = { + clientid: 'json-client', + project: 'json-project' + }; + sinon.stub(fs, 'readFileSync').returns(JSON.stringify(fileConfig)); + + // Set env vars + process.env.QUANT_CLIENT_ID = 'env-client'; + process.env.QUANT_PROJECT = 'env-project'; + + await config.fromArgs({}, true); + + expect(config.get('clientid')).to.equal('env-client'); + expect(config.get('project')).to.equal('env-project'); + }); + + it('should track enableIndexHtml setting', async () => { + // First deployment sets the setting + await config.fromArgs({ 'enable-index-html': true }, true); + expect(config.get('enableIndexHtml')).to.be.true; + + // Second deployment with same setting works + await config.fromArgs({ 'enable-index-html': true }, true); + expect(config.get('enableIndexHtml')).to.be.true; + + // Different setting throws error + try { + await config.fromArgs({ 'enable-index-html': false }, true); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Cannot disable index.html URLs - this project was deployed with index.html URLs enabled'); + } + }); + + it('should handle enableIndexHtml false setting', async () => { + // First deployment sets the setting to false + await config.fromArgs({ 'enable-index-html': false }, true); + expect(config.get('enableIndexHtml')).to.be.false; + + // Try to enable it later + try { + await config.fromArgs({ 'enable-index-html': true }, true); + expect.fail('Should have thrown error'); + } catch (err) { + expect(err.message).to.equal('Cannot enable index.html URLs - this project was deployed with index.html URLs disabled'); + } + }); + + it('should accept short form CLI arguments', async () => { + const args = { + c: 'test-client', + p: 'test-project', + t: 'test-token', + e: 'http://custom.api/v1' + }; + + await config.fromArgs(args, true); + + expect(config.get('clientid')).to.equal('test-client'); + expect(config.get('project')).to.equal('test-project'); + expect(config.get('token')).to.equal('test-token'); + expect(config.get('endpoint')).to.equal('http://custom.api/v1'); + }); + + it('should accept long form CLI arguments', async () => { + const args = { + clientid: 'test-client', + project: 'test-project', + token: 'test-token', + endpoint: 'http://custom.api/v1' + }; + + await config.fromArgs(args, true); + + expect(config.get('clientid')).to.equal('test-client'); + expect(config.get('project')).to.equal('test-project'); + expect(config.get('token')).to.equal('test-token'); + expect(config.get('endpoint')).to.equal('http://custom.api/v1'); + }); + + it('should prioritize long form over short form arguments', async () => { + const args = { + clientid: 'long-client', + c: 'short-client', + project: 'long-project', + p: 'short-project' + }; + + await config.fromArgs(args, true); + + expect(config.get('clientid')).to.equal('long-client'); + expect(config.get('project')).to.equal('long-project'); + }); + }); + + describe('save', () => { + it('should save config to quant.json', () => { + const writeStub = sinon.stub(fs, 'writeFileSync'); + + config.set({ + clientid: 'test-client', + project: 'test-project', + token: 'test-token' + }); + + config.save(); + + expect(writeStub.calledWith('quant.json')).to.be.true; + const savedConfig = JSON.parse(writeStub.firstCall.args[1]); + expect(savedConfig.clientid).to.equal('test-client'); + expect(savedConfig.project).to.equal('test-project'); + expect(savedConfig.token).to.equal('test-token'); + }); + + }); +}); \ No newline at end of file diff --git a/tests/unit/helper/is-md5-match.test.mjs b/tests/unit/helper/is-md5-match.test.mjs new file mode 100644 index 0000000..97811dd --- /dev/null +++ b/tests/unit/helper/is-md5-match.test.mjs @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import isMD5Match from '../../../src/helper/is-md5-match.js'; + +describe('isMD5Match Helper', () => { + it('should detect MD5 match in error response data', () => { + const error = { + response: { + data: { + errorMsg: 'MD5 already matches existing file.' + } + } + }; + expect(isMD5Match(error)).to.be.true; + }); + + it('should detect MD5 match with "Published version" message in response', () => { + const error = { + response: { + data: { + errorMsg: 'Published version already has md5 abc123' + } + } + }; + expect(isMD5Match(error)).to.be.true; + }); + + it('should detect MD5 match in error message', () => { + const error = new Error('MD5 already matches'); + expect(isMD5Match(error)).to.be.true; + }); + + it('should detect MD5 match with "Published version" in error message', () => { + const error = new Error('Published version already has md5 xyz789'); + expect(isMD5Match(error)).to.be.true; + }); + + it('should return false for non-MD5 match errors', () => { + const error = new Error('Some other error'); + expect(isMD5Match(error)).to.be.false; + }); + + it('should handle missing response data', () => { + const error = { + response: {} + }; + expect(isMD5Match(error)).to.be.false; + }); + + it('should handle null error', () => { + expect(isMD5Match(null)).to.be.false; + }); + + it('should handle undefined error', () => { + expect(isMD5Match(undefined)).to.be.false; + }); +}); \ No newline at end of file