diff --git a/.gitignore b/.gitignore index bb6fa4d..2c8fe2e 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,11 @@ dist .dynamodb/ # TernJS port file -.tern-port \ No newline at end of file +.tern-port + +# Visual Studio +/.vs + +# QA harness +/qa/artifacts/ +/qa/profile.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..657eb47 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 2.0.0-beta.0 + +### Breaking changes + +- **Version bumped to 2.0.0** -- this release contains breaking changes to command output, option names, and authentication behavior. +- **`--json` flag replaced by `--output json`** -- the old `--json` flag is deprecated and will be removed in a future release. All commands now use `--output json` for structured output. +- **`--iamstupid` replaced by `--dangerouslyIncludeLogsAndCache`** -- the old flag is deprecated and will be removed in a future release. +- **Error handling changed** -- commands that previously printed an error and exited silently now throw structured errors. In JSON mode, errors are returned in the `errors` array with `ok: false`. The CLI exits with code `1` on any error. + +### New features + +- **OAuth client credentials authentication** -- the CLI now supports OAuth 2.0 client credentials for headless and CI/CD authentication. Use `dw login --oauth` to configure an environment, or pass `--auth oauth` with `--clientIdEnv`/`--clientSecretEnv` (or `--clientId`/`--clientSecret`) on any command. +- **Structured JSON output on all API commands** -- `env`, `login`, `files`, `query`, `command`, and `install` all support `--output json`, returning a consistent envelope with `ok`, `command`, `operation`, `status`, `data`, `errors`, and `meta` fields. +- **File delete operations** -- `dw files --delete` removes files and directories from the environment. Combine with `--empty` to clear a directory without removing it. +- **File copy operations** -- `dw files --copy ` copies files and directories within the environment. +- **File move operations** -- `dw files --move ` moves files and directories within the environment. Combine with `--overwrite` to replace existing files at the destination. +- **Global OAuth flags** -- `--auth`, `--clientId`, `--clientSecret`, `--clientIdEnv`, and `--clientSecretEnv` are available as global options on all commands. +- **Authentication precedence** -- when multiple auth indicators are present, the CLI resolves them in order: `--apiKey` > OAuth > saved user > interactive prompt. Use `--auth user` to force user auth when an environment is configured for OAuth. +- **Base command shows auth type** -- `dw` with no arguments now displays the current authentication type (OAuth or user) alongside environment info. + +### Improvements + +- **Consistent error model** -- all commands use a shared `createCommandError` helper that produces errors with `message`, `status`, and `details`. In JSON mode these are serialized into the `errors` array. +- **Human output suppressed in JSON mode** -- when `--output json` is active, all `console.log` output is suppressed. Only the JSON envelope is written to stdout, making it safe to pipe. +- **Interactive prompts skipped in JSON mode** -- delete confirmations and other interactive prompts are skipped when `--output json` is set, enabling fully non-interactive scripted usage. +- **Better host override handling** -- `--host` now works with OAuth credentials, not just `--apiKey`. +- **Null-safe config access** -- environment and user lookups use optional chaining to avoid crashes on missing config keys. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33b283f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`@dynamicweb/cli` is a Node.js CLI (`dw`) for managing DynamicWeb 10 CMS solutions. It handles authentication, file archive operations, Admin API queries/commands, add-in installation, database exports, and Swift release downloads. The binary is registered as `dw`. + +## Development Setup + +```bash +npm install +npm install -g . # Makes 'dw' available globally from source +dw --help +``` + +No build step — pure ESM JavaScript (`"type": "module"`), Node.js >=20.12.0 required. + +No test framework or linting is configured yet. + +## Code Architecture + +### Entry Point + +[bin/index.js](bin/index.js) bootstraps yargs with global options and registers all commands. `setupConfig()` runs at startup to initialize `~/.dwc`. + +### Command Structure + +All commands live in [bin/commands/](bin/commands/). Each exports a `*Command()` function returning a yargs command object with `command`, `describe`, `builder`, and `handler` properties. + +Every command handler follows the same pattern: + +```js +handler: async (argv) => { + const output = createXxxOutput(argv); // local output envelope + try { + let env = await setupEnv(argv, output); // from env.js + let user = await setupUser(argv, env); // from login.js + // ... API calls with node-fetch + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); // prints JSON if --output json + } +} +``` + +### Key Shared Modules + +- **[bin/commands/env.js](bin/commands/env.js)** — `setupEnv()`, `getAgent()` (keep-alive HTTP/HTTPS agents), `createCommandError()`, `isJsonOutput()`, `interactiveEnv()` +- **[bin/commands/login.js](bin/commands/login.js)** — `setupUser()`, OAuth token fetch, interactive login, API key creation +- **[bin/commands/config.js](bin/commands/config.js)** — `getConfig()`, `updateConfig()`, `setupConfig()` — manages `~/.dwc` JSON file +- **[bin/utils.js](bin/utils.js)** — `createThrottledStatusUpdater()` (500ms throttle), `formatBytes()`, `formatElapsed()` +- **[bin/downloader.js](bin/downloader.js)** — streams HTTP responses with a progress callback +- **[bin/extractor.js](bin/extractor.js)** — ZIP extraction with progress callback + +### Output Envelope + +Each command creates a local output object (see `createEnvOutput` in [bin/commands/env.js:256](bin/commands/env.js#L256) as the canonical example): + +```js +{ ok, command, operation, status, data: [], errors: [], meta: {} } +``` + +- `output.addData(entry)` — push to `data[]` +- `output.log(...args)` — console.log only when not in JSON mode +- `output.fail(err)` — sets `ok: false`, pushes to `errors[]` +- `output.finish()` — prints `JSON.stringify(response)` if `--output json` + +### Authentication + +`shouldUseOAuth()` in [bin/commands/login.js](bin/commands/login.js) decides auth mode from flags, env config, or CLI args. Both paths converge to `user.apiKey` for API calls. + +- **User auth**: interactive login → creates a DW API key stored in `~/.dwc` +- **OAuth**: fetches access token from `/Admin/OAuth/token`; token not cached between commands + +### Config File (`~/.dwc`) + +```json +{ + "env": { + "": { + "host": "localhost:6001", + "protocol": "https", + "users": { "": { "apiKey": "prefix.key" } }, + "auth": { "type": "oauth_client_credentials", "clientIdEnv": "...", "clientSecretEnv": "..." }, + "current": { "user": "...", "authType": "user|oauth_client_credentials" } + } + }, + "current": { "env": "" } +} +``` + +### Global CLI Flags + +All commands inherit: `--verbose/-v`, `--host`, `--protocol`, `--apiKey`, `--auth user|oauth`, `--clientId`, `--clientSecret`, `--clientIdEnv`, `--clientSecretEnv`, `--output json`. + +When `--output json` is set, interactive prompts are skipped and the structured envelope is printed to stdout. All other logging must go through `output.log()` (not `console.log()` directly) so it is suppressed in JSON mode. + +### HTTPS Agent + +The HTTPS agent in [bin/commands/env.js:13](bin/commands/env.js#L13) sets `rejectUnauthorized: false` intentionally to support self-signed certificates in dev environments. + +### Git Bash Warning + +On Windows Git Bash (`MSYSTEM` env var set), the CLI warns about path conversion unless `MSYS_NO_PATHCONV=1`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f591de0 --- /dev/null +++ b/README.md @@ -0,0 +1,534 @@ +# DynamicWeb CLI + +A CLI for managing DynamicWeb 10 solutions — environments, authentication, queries, commands, files, add-ins, databases, and Swift releases. + +| Tool | Best for | +|------|----------| +| **DW CLI** (this repo) | Automation, CI/CD pipelines, scripting, headless environments | +| **[DW Desktop](https://github.com/dynamicweb/dw-desktop)** | Day-to-day developer workflows with a GUI | + +This branch now targets `2.0.0-preview.1`. + +## Requirements + +- Node.js `>=20.12.0` + +## Install + +Install the preview from npm: + +```sh +npm install -g @dynamicweb/cli@preview +dw --help +``` + +Install from npm: + +```sh +npm install -g @dynamicweb/cli +dw --help +``` + +Install from source: + +```sh +npm install +npm install -g . +``` + +## What Changed + +The `2.0` preview is a substantial overhaul focused on automation and modern authentication. + +- Automation-first command output: `env`, `login`, `files`, `folders`, `query`, `command`, and `install` now support `--output json` so scripts and pipelines can consume structured results instead of plain console logs. +- OAuth client credentials support: the CLI can now authenticate with OAuth 2.0 `client_credentials`, which makes headless and CI/CD usage much easier. +- Better environment handling: protocol, host, and auth details are stored more cleanly in `~/.dwc`, while one-off runs can still override host and credentials directly. +- Improved file workflows: file import, export, recursive sync, raw archive export, progress reporting, and source-type override flags make file operations more predictable. +- Clearer error reporting: commands can now return structured failures in JSON mode, which is much easier to handle in automation. + +## Quick Start + +View all available commands: + +```sh +dw --help +dw --help +``` + +Set up an environment and log in with a user: + +```sh +dw env dev +dw login +dw # shows current environment, user, protocol, and host +``` + +Run a query: + +```sh +dw query HealthCheck +``` + +## Authentication + +### Interactive User Login + +The default login flow uses a DynamicWeb user account. The CLI logs in, creates an API key, and stores it in `~/.dwc`. + +```sh +dw login +dw login +dw env +``` + +A user-authenticated config typically looks like this: + +```json +{ + "env": { + "dev": { + "host": "localhost:6001", + "protocol": "https", + "users": { + "DemoUser": { + "apiKey": "." + } + }, + "current": { + "user": "DemoUser", + "authType": "user" + } + } + }, + "current": { + "env": "dev" + } +} +``` + +### OAuth Client Credentials + +For service accounts, automation, and headless usage, the CLI also supports OAuth 2.0 `client_credentials`. + +Configure an environment for OAuth: + +```sh +export DW_CLIENT_ID=my-client-id +export DW_CLIENT_SECRET=my-client-secret + +dw login --oauth +``` + +Run a one-off command with OAuth flags instead of saved config: + +```sh +dw query HealthCheck \ + --host your-solution.example.com \ + --auth oauth \ + --clientIdEnv DW_CLIENT_ID \ + --clientSecretEnv DW_CLIENT_SECRET \ + --output json +``` + +An OAuth-enabled environment in `~/.dwc` looks like this: + +```json +{ + "env": { + "dev": { + "host": "localhost:6001", + "protocol": "https", + "auth": { + "type": "oauth_client_credentials", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET" + }, + "current": { + "authType": "oauth_client_credentials" + } + } + }, + "current": { + "env": "dev" + } +} +``` + +## Global Options + +Most API-driven commands support these global options: + +- `-v`, `--verbose`: enable verbose logging +- `--host`: use a host directly instead of the saved environment +- `--protocol`: set the protocol used with `--host` and default to `https` +- `--apiKey`: use an API key for environmentless execution +- `--oauth`: use OAuth client credentials authentication (shorthand for `--auth oauth`) +- `--auth`: override authentication mode with `user` or `oauth` +- `--clientId`: pass an OAuth client ID directly +- `--clientSecret`: pass an OAuth client secret directly +- `--clientIdEnv`: read the OAuth client ID from an environment variable +- `--clientSecretEnv`: read the OAuth client secret from an environment variable + +## JSON Output for Automation + +Commands that support `--output json` return a machine-readable envelope with `ok`, `status`, `data`, `errors`, and `meta` fields. + +Examples: + +```sh +dw env --list --output json +dw login --output json +dw query FileByName --name DefaultMail.html --output json +``` + +Representative output: + +```json +{ + "ok": true, + "command": "env", + "operation": "list", + "status": 0, + "data": [ + { + "environments": ["dev", "staging"] + } + ], + "errors": [], + "meta": {} +} +``` + +## Commands + +### `dw env [env]` + +Create, select, or inspect saved environments. + +```sh +dw env dev +dw env --list +dw env --users +dw env --list --output json +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "env", + "operation": "select", + "status": 0, + "data": [ + { + "environment": "dev", + "current": "dev" + } + ], + "errors": [], + "meta": {} +} +``` + +### `dw login [user]` + +Log in interactively, configure OAuth, or switch between saved users for the current environment. + +```sh +dw login +dw login DemoUser +dw login --oauth +dw login --output json +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "login", + "operation": "oauth-login", + "status": 0, + "data": [ + { + "environment": "dev", + "authType": "oauth_client_credentials", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET", + "expires": "2026-04-13T14:22:31Z" + } + ], + "errors": [], + "meta": {} +} +``` + +### `dw files [dirPath] [outPath]` + +List, export, import, delete, copy, and move files and directories in the DynamicWeb file archive. + +Useful flags: + +- `-l`, `--list`: list directories +- `-f`, `--includeFiles`: include files in listings +- `-e`, `--export`: export from the environment to disk +- `-i`, `--import`: import from disk to the environment +- `-d`, `--delete`: delete a file or directory +- `--empty`: used with `--delete`, empties a directory instead of removing it +- `--copy `: copy a file or directory to a destination path +- `--move `: move a file or directory to a destination path +- `-r`, `--recursive`: recurse through subdirectories +- `--raw`: keep downloaded archives zipped +- `--dangerouslyIncludeLogsAndCache`: include log and cache folders during export, which is risky and usually not recommended +- `-af`, `--asFile`: force the source path to be treated as a file +- `-ad`, `--asDirectory`: force the source path to be treated as a directory + +Examples: + +```sh +dw files templates ./templates -fre +dw files system -lr +dw files templates/Translations.xml ./templates -e +dw files templates/templates.v1 ./templates -e -ad +dw files ./Files templates -i -r --output json +dw files /Templates/OldDesign --delete +dw files /Templates/MyDesign --copy /Templates/MyDesign-backup +dw files /Templates/OldName --move /Templates/Archive +``` + +### `dw folders ` + +Create, rename, move, delete, copy, and export directories. Where `dw files` detects file vs. directory from the path extension, `dw folders` always treats the path as a directory — no ambiguity. + +Useful flags: + +- `-c`, `--create`: create the directory at `` +- `--rename `: rename the directory to a new name (keeps it in the same parent) +- `-m`, `--move `: move the directory to the given destination path +- `-d`, `--delete`: delete the directory +- `--empty`: used with `--delete`, empties the directory instead of removing it +- `--copy `: copy the directory to the given destination path +- `-e`, `--export`: export the directory to disk +- `-o`, `--outPath`: local destination for `--export` (defaults to `.`) +- `--raw`: keep exported archives zipped instead of extracting + +Examples: + +```sh +dw folders /Files/NewFolder --create +dw folders /Files/OldName --rename NewName +dw folders /Files/MyFolder --move /Files/Archive +dw folders /Files/MyFolder --delete +dw folders /Files/MyFolder --delete --empty +dw folders /Files/MyFolder --copy /Files/MyFolder-backup +dw folders /Files/MyFolder --export --outPath ./local +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "files", + "operation": "import", + "status": 0, + "data": [ + { + "type": "upload", + "destinationPath": "templates", + "files": [ + "/workspace/Files/Templates/DefaultMail.html" + ], + "response": { + "message": "Upload completed" + } + } + ], + "errors": [], + "meta": { + "filesProcessed": 1, + "chunks": 1 + } +} +``` + +### `dw query [query]` + +Run admin API queries, inspect available parameters, or prompt for them interactively. + +```sh +dw query FileByName -l +dw query FileByName --name DefaultMail.html --directorypath /Templates/Forms/Mail +dw query FileByName --interactive +dw query FileByName --name DefaultMail.html --output json +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "query", + "operation": "run", + "status": 0, + "data": [ + { + "name": "DefaultMail.html", + "path": "/Templates/Forms/Mail/DefaultMail.html" + } + ], + "errors": [], + "meta": { + "query": "FileByName" + } +} +``` + +### `dw command [command]` + +Run admin API commands and pass a JSON payload either inline or by file path. + +```sh +dw command PageCopy --json '{ "model": { "SourcePageId": 1189, "DestinationParentPageId": 1129 } }' +dw command PageMove --json ./PageMove.json +dw command PageDelete --json '{ "id": "1383" }' --output json +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "command", + "operation": "run", + "status": 0, + "data": [ + { + "success": true, + "message": "Command executed" + } + ], + "errors": [], + "meta": { + "commandName": "PageDelete" + } +} +``` + +`dw command --list` is reserved for command metadata, but it is not fully implemented yet. + +### `dw install [filePath]` + +Upload and install a `.dll` or `.nupkg` add-in into the current environment. + +```sh +dw install ./bin/Release/net10.0/CustomProject.dll +dw install ./bin/Release/net10.0/CustomProject.dll --queue --output json +``` + +Example JSON output: + +```json +{ + "ok": true, + "command": "install", + "operation": "queue", + "status": 0, + "data": [ + { + "type": "upload", + "destinationPath": "System/AddIns/Local", + "files": [ + "/workspace/bin/Release/net10.0/CustomProject.dll" + ], + "response": { + "message": "Upload completed" + } + }, + { + "type": "install", + "filePath": "/workspace/bin/Release/net10.0/CustomProject.dll", + "filename": "CustomProject.dll", + "queued": true, + "response": { + "success": true, + "message": "Addin installed" + } + } + ], + "errors": [], + "meta": { + "filePath": "./bin/Release/net10.0/CustomProject.dll", + "filesProcessed": 1, + "chunks": 1 + } +} +``` + +### `dw config` + +Write values directly into `~/.dwc` when you want to script config updates. + +```sh +dw config --env.dev.host localhost:6001 +``` + +### `dw database [path] --export` + +Export the current environment database to a `.bacpac` file. + +```sh +dw database ./backups --export +``` + +### `dw swift [outPath]` + +Download the latest Swift release, a specific tag, or the nightly build. + +```sh +dw swift -l +dw swift . --tag v2.3.0 --force +dw swift . --nightly --force +``` + +## CI/CD + +For CI/CD, prefer OAuth client credentials and JSON output. + +- Store `DW_CLIENT_ID` and `DW_CLIENT_SECRET` in your pipeline secret store. +- Use `--host` together with `--auth oauth` for ephemeral runners. +- Add `--output json` when you want reliable parsing in scripts. + +Example: + +```sh +dw query HealthCheck \ + --host your-solution.example.com \ + --auth oauth \ + --clientIdEnv DW_CLIENT_ID \ + --clientSecretEnv DW_CLIENT_SECRET \ + --output json +``` + +For longer-lived runners, you can configure a saved environment once with `dw login --oauth`. Full CI/CD guidance will be expanded in the documentation. + +## QA Smoke Testing + +This repository now includes a reusable QA harness in [qa/README.md](qa/README.md). It runs the CLI against a real DynamicWeb solution using OAuth client credentials, keeps its own isolated `HOME`, and can exercise both: + +- saved-environment developer flow +- ephemeral CI/CD-style flow with `--host --auth oauth` + +The harness currently excludes `database` and `swift`. + +## Using Git Bash + +Git Bash can rewrite relative paths in a way that interferes with CLI file operations. If you see the path-conversion warning, disable it for the session before running file commands: + +```sh +export MSYS_NO_PATHCONV=1 +dw files -iro ./ ./TestFolder --host --apiKey +``` + +If you do not want to change that setting, prefer `./`-prefixed paths or use PowerShell or CMD instead. diff --git a/bin/commands/command.js b/bin/commands/command.js new file mode 100644 index 0000000..d93c3f9 --- /dev/null +++ b/bin/commands/command.js @@ -0,0 +1,150 @@ +import fetch from 'node-fetch'; +import path from 'path'; +import fs from 'fs'; +import { setupEnv, getAgent, createCommandError } from './env.js'; +import { setupUser } from './login.js'; + +const exclude = ['_', '$0', 'command', 'list', 'json', 'verbose', 'v', 'host', 'protocol', 'apiKey', 'env', 'output', 'auth', 'clientId', 'clientSecret', 'clientIdEnv', 'clientSecretEnv', 'oauth'] + +export function commandCommand() { + return { + command: 'command [command]', + describe: 'Runs the given command', + builder: (yargs) => { + return yargs + .positional('command', { + describe: 'The command to execute' + }) + .option('json', { + describe: 'Literal json or location of json file to send' + }) + .option('list', { + alias: 'l', + describe: 'Lists all the properties for the command, currently not working' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) + }, + handler: async (argv) => { + const output = createCommandOutput(argv); + + try { + output.verboseLog(`Running command ${argv.command}`); + await handleCommand(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } + } + } +} + +async function handleCommand(argv, output) { + let env = await setupEnv(argv, output); + let user = await setupUser(argv, env); + if (argv.list) { + const properties = await getProperties(env, user, argv.command); + output.addData(properties); + output.log(properties); + } else { + let response = await runCommand(env, user, argv.command, getQueryParams(argv), parseJsonOrPath(argv.json)); + output.addData(response); + output.log(response); + } +} + +async function getProperties(env, user, command) { + throw createCommandError('The --list option is not currently implemented for commands.'); +} + +export function getQueryParams(argv) { + let params = {} + Object.keys(argv).filter(k => !exclude.includes(k)).forEach(k => params['Command.' + k] = argv[k]) + return params +} + +export function parseJsonOrPath(json) { + if (!json) return + const trimmed = json.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return JSON.parse(trimmed); + } + if (fs.existsSync(json)) { + return JSON.parse(fs.readFileSync(path.resolve(json))); + } + return JSON.parse(json); +} + +async function runCommand(env, user, command, queryParams, data) { + let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${command}?` + new URLSearchParams(queryParams), { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }) + if (!res.ok) { + throw createCommandError(`Error when doing request ${res.url}`, res.status, await parseJsonSafe(res)); + } + return await res.json() +} + +function createCommandOutput(argv) { + const response = { + ok: true, + command: 'command', + operation: argv.list ? 'list' : 'run', + status: 0, + data: [], + errors: [], + meta: { + commandName: argv.command + } + }; + + return { + json: argv.output === 'json', + response, + log(value) { + if (!this.json) { + console.log(value); + } + }, + verboseLog(...args) { + if (argv.verbose && !this.json) { + console.info(...args); + } + }, + addData(entry) { + response.data.push(entry); + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} + + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; + } +} diff --git a/bin/commands/config.js b/bin/commands/config.js index c9d9c22..c249202 100644 --- a/bin/commands/config.js +++ b/bin/commands/config.js @@ -7,7 +7,7 @@ let localConfig; export function configCommand() { return { command: 'config', - describe: 'Edit the configs located in usr/.dwc', + describe: 'Edit the configs located in ~/.dwc', handler: (argv) => handleConfig(argv), builder: { prop: { @@ -27,13 +27,25 @@ export function setupConfig() { } export function getConfig() { - return localConfig; + return localConfig || {}; +} + +/** + * Overrides the in-memory config for testing. + * @param {Object} config - Must be a plain, non-null object; handleConfig writes keys directly onto it. + */ +export function setConfigForTests(config) { + if (config === null || typeof config !== 'object') { + throw new Error('setConfigForTests: config must be a plain object'); + } + localConfig = config; } export function handleConfig(argv) { + localConfig = localConfig || {}; Object.keys(argv).forEach(a => { if (a != '_' && a != '$0') { - resolveConfig(a, argv[a], config[a]); + localConfig[a] = resolveConfig(a, argv[a], localConfig[a] || {}); updateConfig(); } }) @@ -52,4 +64,4 @@ function resolveConfig(key, obj, conf) { conf[a] = resolveConfig(key, obj[a], conf[a]); }) return conf; -} \ No newline at end of file +} diff --git a/bin/commands/database.js b/bin/commands/database.js index fad72fd..adab4d3 100644 --- a/bin/commands/database.js +++ b/bin/commands/database.js @@ -1,14 +1,9 @@ import fetch from 'node-fetch'; import fs from 'fs'; import _path from 'path'; -import { Agent } from 'https'; -import { setupEnv } from './env.js'; +import { setupEnv, getAgent } from './env.js'; import { setupUser } from './login.js'; -const agent = new Agent({ - rejectUnauthorized: false -}) - export function databaseCommand() { return { command: 'database [path]', @@ -25,9 +20,9 @@ export function databaseCommand() { description: 'Exports the solutions database to a .bacpac file at [path]' }) }, - handler: (argv) => { + handler: async (argv) => { if (argv.verbose) console.info(`Handling database with path: ${argv.path}`) - handleDatabase(argv) + await handleDatabase(argv) } } } @@ -42,35 +37,30 @@ async function handleDatabase(argv) { } async function download(env, user, path, verbose) { - let filename = 'database.bacpac'; - fetch(`https://${env.host}/Admin/Api/DatabaseDownload`, { + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/DatabaseDownload`, { method: 'POST', headers: { - 'Authorization': `Bearer ${user.apiKey}` + 'Authorization': `Bearer ${user.apiKey}`, + 'content-type': 'application/json' }, - agent: agent - }).then(async (res) => { - if (verbose) console.log(res) - const header = res.headers.get('Content-Disposition'); - const parts = header?.split(';'); - if (!parts || !header.includes('attachment')) { - console.log('Failed download, check users database permissions') - if (verbose) console.log(await res.json()) - return; - } - filename = parts[1].split('=')[1]; - return res; - }).then(async (res) => { - if (!res) { - return; - } - const fileStream = fs.createWriteStream(_path.resolve(`${_path.resolve(path)}/${filename}`)); - await new Promise((resolve, reject) => { - res.body.pipe(fileStream); - res.body.on("error", reject); - fileStream.on("finish", resolve); - }); - console.log(`Finished downloading`); - return res; + agent: getAgent(env.protocol) + }); + + if (verbose) console.log(res) + const header = res.headers.get('Content-Disposition'); + const parts = header?.split(';'); + if (!parts || !header.includes('attachment')) { + console.log('Failed download, check users database permissions') + if (verbose) console.log(await res.json()) + process.exit(1); + } + + const filename = parts[1].split('=')[1]; + const fileStream = fs.createWriteStream(_path.resolve(`${_path.resolve(path)}/${filename}`)); + await new Promise((resolve, reject) => { + res.body.pipe(fileStream); + res.body.on("error", reject); + fileStream.on("finish", resolve); }); + console.log(`Finished downloading`); } \ No newline at end of file diff --git a/bin/commands/env.js b/bin/commands/env.js index ef6e555..b9b9feb 100644 --- a/bin/commands/env.js +++ b/bin/commands/env.js @@ -1,5 +1,50 @@ import { updateConfig, getConfig } from './config.js' -import yargsInteractive from 'yargs-interactive'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; +import { input } from '@inquirer/prompts'; + +const httpAgent = new HttpAgent({ + keepAlive: true, + maxSockets: 8, + maxFreeSockets: 4, + keepAliveMsecs: 10_000 +}); + +const httpsAgent = new HttpsAgent({ + keepAlive: true, + maxSockets: 8, + maxFreeSockets: 4, + keepAliveMsecs: 10_000, + rejectUnauthorized: false +}); + +export function getAgent(protocol) { + return protocol === 'http' ? httpAgent : httpsAgent; +} + +export function parseHostInput(hostValue) { + if (!hostValue || typeof hostValue !== 'string' || !hostValue.trim()) { + throw createCommandError(`Invalid host value: ${hostValue}`); + } + hostValue = hostValue.trim(); + const hostSplit = hostValue.split('://'); + + if (hostSplit.length === 1) { + return { + protocol: 'https', + host: hostSplit[0] + }; + } + + if (hostSplit.length === 2) { + return { + protocol: hostSplit[0], + host: hostSplit[1] + }; + } + + throw createCommandError(`Issues resolving host ${hostValue}`); +} export function envCommand() { return { @@ -20,75 +65,166 @@ export function envCommand() { type: 'boolean', description: 'List all users in environment, uses positional [env] if used, otherwise current env' }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) }, - handler: (argv) => handleEnv(argv) + handler: async (argv) => { + const output = createEnvOutput(argv); + + try { + await handleEnv(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } + } } } -export async function setupEnv(argv) { - let env; - if (getConfig().env) { - env = getConfig().env[argv.env] || getConfig().env[getConfig()?.current?.env]; +export async function setupEnv(argv, output = null, deps = {}) { + const interactiveEnvFn = deps.interactiveEnvFn || interactiveEnv; + const cfg = getConfig(); + let env = {}; + let askEnv = true; + + if (argv.host) { + askEnv = false; + env.host = argv.host; + if (argv.protocol) { + env.protocol = argv.protocol; + } else { + env.protocol = 'https'; + } } - if (!env) { - console.log('Current environment not set, please set it') - await interactiveEnv(argv, { + + if (askEnv && cfg.env) { + env = cfg.env[argv.env] || cfg.env[cfg?.current?.env]; + if (env && !env.protocol) { + logMessage(argv, 'Protocol for environment not set, defaulting to https'); + env.protocol = 'https'; + } + } + else if (askEnv) { + if (isJsonOutput(argv)) { + throw createCommandError('Current environment not set, please set it'); + } + + logMessage(argv, 'Current environment not set, please set it'); + await interactiveEnvFn(argv, { environment: { type: 'input' }, interactive: { default: true } - }) - env = getConfig().env[getConfig()?.current?.env]; + }, output) + const updatedConfig = getConfig(); + env = updatedConfig.env?.[updatedConfig?.current?.env]; + } + + if (!env || Object.keys(env).length === 0) { + throw createCommandError('Unable to resolve the current environment.'); } + return env; } -async function handleEnv(argv) { +async function handleEnv(argv, output) { if (argv.users) { - let env = argv.env || getConfig().current.env; - console.log(`Users in environment ${env}: ${Object.keys(getConfig().env[env].users || {})}`); + const cfg = getConfig(); + let env = argv.env || cfg.current?.env; + const envConfig = cfg.env?.[env]; + if (!envConfig) { + throw createCommandError(`Environment '${env}' does not exist`, 404); + } + const users = Object.keys(envConfig.users || {}); + output.addData({ environment: env, users }); + output.log(`Users in environment ${env}: ${users}`); } else if (argv.env) { - changeEnv(argv) + const result = await changeEnv(argv, output); + if (result !== null) { + output.addData(result); + } } else if (argv.list) { - console.log(`Existing environments: ${Object.keys(getConfig().env || {})}`) + const environments = Object.keys(getConfig().env || {}); + output.addData({ environments }); + output.log(`Existing environments: ${environments}`); } else { - interactiveEnv(argv, { + await interactiveEnv(argv, { environment: { type: 'input' }, host: { + describe: 'Enter your host including protocol, i.e "https://yourHost.com":', type: 'input' }, interactive: { default: true } - }) + }, output) } } -export async function interactiveEnv(argv, options) { - if (argv.verbose) console.info('Setting up new environment') - await yargsInteractive() - .interactive(options) - .then(async (result) => { - getConfig().env = getConfig().env || {}; - getConfig().env[result.environment] = getConfig().env[result.environment] || {}; - if (result.host) - getConfig().env[result.environment].host = result.host; - if (result.environment) { - getConfig().current = getConfig().current || {}; - getConfig().current.env = result.environment; - } - updateConfig(); - console.log(`Your current environment is now ${getConfig().current.env}`); +export async function interactiveEnv(argv, options, output) { + verboseLog(argv, 'Setting up new environment'); + const result = {}; + for (const [key, config] of Object.entries(options)) { + if (key === 'interactive') continue; + if (config.prompt === 'never') { + result[key] = config.default; + continue; + } + result[key] = await input({ + message: config.describe || key, + default: config.default }); + } + getConfig().env = getConfig().env || {}; + if (!result.environment || !result.environment.trim()) { + throw createCommandError('Environment name cannot be empty'); + } + getConfig().env[result.environment] = getConfig().env[result.environment] || {}; + if (result.host) { + const resolvedHost = parseHostInput(result.host); + getConfig().env[result.environment].protocol = resolvedHost.protocol; + getConfig().env[result.environment].host = resolvedHost.host; + } + if (result.environment) { + getConfig().current = getConfig().current || {}; + getConfig().current.env = result.environment; + } + updateConfig(); + logMessage(argv, `Your current environment is now ${getConfig().current.env}`); + logMessage(argv, `To change the host of your environment, use the command 'dw env'`); + + const currentEnv = getConfig().env[result.environment]; + const data = { + environment: result.environment, + protocol: currentEnv.protocol || null, + host: currentEnv.host || null, + current: getConfig().current.env + }; + + if (output) { + output.addData(data); + } + + return data; } -async function changeEnv(argv) { - if (!Object.keys(getConfig().env).includes(argv.env)) { - console.log(`The specified environment ${argv.env} doesn't exist, please create it`); +async function changeEnv(argv, output) { + const environments = getConfig().env || {}; + + if (!Object.hasOwn(environments, argv.env)) { + if (isJsonOutput(argv)) { + throw createCommandError(`The specified environment ${argv.env} doesn't exist, please create it`, 404); + } + + logMessage(argv, `The specified environment ${argv.env} doesn't exist, please create it`); await interactiveEnv(argv, { environment: { type: 'input', @@ -96,22 +232,86 @@ async function changeEnv(argv) { prompt: 'never' }, host: { + describe: 'Enter your host including protocol, i.e "https://yourHost.com":', type: 'input', prompt: 'always' }, interactive: { default: true } - }) + }, output) + return null; } else { getConfig().current.env = argv.env; updateConfig(); - console.log(`Your current environment is now ${getConfig().current.env}`); + const data = { + environment: argv.env, + current: getConfig().current.env + }; + logMessage(argv, `Your current environment is now ${getConfig().current.env}`); + if (output) { + output.addData(data); + } + return null; } } -async function changeUser(argv) { - getConfig().env[getConfig().current.env].current.user = argv.user; - updateConfig(); - console.log(`You're now logged in as ${getConfig().env[getConfig().current.env].current.user}`); -} \ No newline at end of file +export function isJsonOutput(argv) { + return argv?.output === 'json'; +} + +export function createCommandError(message, status = 1, details = null) { + const error = new Error(message); + error.status = status; + error.details = details; + return error; +} + +function logMessage(argv, ...args) { + if (!isJsonOutput(argv)) { + console.log(...args); + } +} + +function verboseLog(argv, ...args) { + if (argv?.verbose && !isJsonOutput(argv)) { + console.info(...args); + } +} + +function createEnvOutput(argv) { + const response = { + ok: true, + command: 'env', + operation: argv.users ? 'users' : argv.list ? 'list' : argv.env ? 'select' : 'setup', + status: 0, + data: [], + errors: [], + meta: {} + }; + + return { + json: isJsonOutput(argv), + addData(entry) { + response.data.push(entry); + }, + log(...args) { + if (!this.json) { + console.log(...args); + } + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown env command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} diff --git a/bin/commands/files.js b/bin/commands/files.js index 18bafc7..9ce8a20 100644 --- a/bin/commands/files.js +++ b/bin/commands/files.js @@ -1,91 +1,255 @@ import fetch from 'node-fetch'; import path from 'path'; import fs from 'fs'; -import { Agent } from 'https'; -import { setupEnv } from './env.js'; +import FormData from 'form-data'; +import { setupEnv, getAgent, createCommandError } from './env.js'; import { setupUser } from './login.js'; -import { interactiveConfirm } from '../utils.js'; - -const agent = new Agent({ - rejectUnauthorized: false -}) +import { interactiveConfirm, formatBytes, createThrottledStatusUpdater } from '../utils.js'; +import { downloadWithProgress, tryGetFileNameFromResponse } from '../downloader.js'; +import { extractWithProgress } from '../extractor.js'; export function filesCommand() { return { - command: 'files [dirPath] [outPath]', - describe: 'Handles files', + command: 'files [dirPath] [outPath]', + describe: 'Handles files', builder: (yargs) => { return yargs - .positional('dirPath', { - describe: 'The directory to list or export' - }) - .positional('outPath', { - describe: 'The directory to export the specified directory to', - default: '.' - }) - .option('list', { - alias: 'l', - type: 'boolean', - description: 'Lists all directories and files' - }) - .option('export', { - alias: 'e', - type: 'boolean', - description: 'Exports the directory at [dirPath] to [outPath]' - }) - .option('includeFiles', { - alias: 'f', - type: 'boolean', - description: 'Includes files in list of directories and files' - }) - .option('recursive', { - alias: 'r', - type: 'boolean', - description: 'Handles all directories recursively' - }) - .option('iamstupid', { - type: 'boolean', - description: 'Includes export of log and cache folders, NOT RECOMMENDED' - }) + .positional('dirPath', { + describe: 'The directory to list or export' + }) + .positional('outPath', { + describe: 'The directory to export the specified directory to', + default: '.' + }) + .option('list', { + alias: 'l', + type: 'boolean', + describe: 'Lists all directories and files' + }) + .option('export', { + alias: 'e', + type: 'boolean', + describe: 'Exports the specified directory and all subdirectories at [dirPath] to [outPath]' + }) + .option('import', { + alias: 'i', + type: 'boolean', + describe: 'Imports the file at [dirPath] to [outPath]' + }) + .option('overwrite', { + alias: 'o', + type: 'boolean', + describe: 'Used with import, will overwrite existing files at destination if set to true' + }) + .option('createEmpty', { + type: 'boolean', + describe: 'Used with import, will create a file even if its empty' + }) + .option('includeFiles', { + alias: 'f', + type: 'boolean', + describe: 'Used with export, includes files in list of directories and files' + }) + .option('recursive', { + alias: 'r', + type: 'boolean', + describe: 'Used with list, import and export, handles all directories recursively' + }) + .option('raw', { + type: 'boolean', + describe: 'Used with export, keeps zip file instead of unpacking it' + }) + .option('dangerouslyIncludeLogsAndCache', { + type: 'boolean', + describe: 'Includes log and cache folders during export. Risky and usually not recommended' + }) + .option('iamstupid', { + type: 'boolean', + hidden: true, + describe: 'Deprecated alias for --dangerouslyIncludeLogsAndCache' + }) + .option('delete', { + alias: 'd', + type: 'boolean', + describe: 'Deletes the file or directory at [dirPath]. Detects type from path (use --asFile/--asDirectory to override)' + }) + .option('empty', { + type: 'boolean', + describe: 'Used with --delete, empties a directory instead of deleting it' + }) + .option('copy', { + type: 'string', + describe: 'Copies the file or directory at [dirPath] to the given destination path' + }) + .option('move', { + type: 'string', + describe: 'Moves the file or directory at [dirPath] to the given destination path' + }) + .option('asFile', { + type: 'boolean', + alias: 'af', + describe: 'Forces the command to treat the path as a single file, even if it has no extension.', + conflicts: 'asDirectory' + }) + .option('asDirectory', { + type: 'boolean', + alias: 'ad', + describe: 'Forces the command to treat the path as a directory, even if its name contains a dot.', + conflicts: 'asFile' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) + .option('json', { + type: 'boolean', + hidden: true, + describe: 'Deprecated alias for --output json' + }) }, - handler: (argv) => { - if (argv.verbose) console.info(`Listing directory at: ${argv.dirPath}`) - handleFiles(argv) + handler: async (argv) => { + if (argv.json && !argv.output) { + argv.output = 'json'; + console.warn('Warning: --json is deprecated and will be removed in a future release. Use --output json instead.'); + } + if (argv.iamstupid && !argv.dangerouslyIncludeLogsAndCache) { + argv.dangerouslyIncludeLogsAndCache = true; + console.warn('Warning: --iamstupid is deprecated and will be removed in a future release. Use --dangerouslyIncludeLogsAndCache instead.'); + } + const output = createFilesOutput(argv); + + try { + await handleFiles(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } } } } -async function handleFiles(argv) { - let env = await setupEnv(argv); +export async function handleFiles(argv, output) { + let env = await setupEnv(argv, output); let user = await setupUser(argv, env); if (argv.list) { + output.verboseLog(`Listing directory at: ${argv.dirPath}`); let files = (await getFilesStructure(env, user, argv.dirPath, argv.recursive, argv.includeFiles)).model; - console.log(files.name) - let hasFiles = files.files?.data && files.files?.data.length !== 0; - resolveTree(files.directories, '', hasFiles); - resolveTree(files.files?.data ?? [], '', false); + output.setStatus(200); + output.addData(files); + if (!output.json) { + output.log(files.name); + let hasFiles = files.files?.data && files.files?.data.length !== 0; + resolveTree(files.directories, '', hasFiles, output); + resolveTree(files.files?.data ?? [], '', false, output); + } } if (argv.export) { if (argv.dirPath) { - await download(env, user, argv.dirPath, argv.outPath, argv.recursive); + + const isFile = isFilePath(argv, argv.dirPath); + + if (isFile) { + let parentDirectory = path.dirname(argv.dirPath); + parentDirectory = parentDirectory === '.' ? '/' : parentDirectory; + + await download(env, user, parentDirectory, argv.outPath, false, null, true, argv.dangerouslyIncludeLogsAndCache, [argv.dirPath], true, output); + } else { + await download(env, user, argv.dirPath, argv.outPath, true, null, argv.raw, argv.dangerouslyIncludeLogsAndCache, [], false, output); + } } else { - await interactiveConfirm('Are you sure you want a full export of files?', async () => { - console.log('Full export is starting') - let dirs = (await getFilesStructure(env, user, '', false, false)).model.directories; + const fullExport = async () => { + output.log('Full export is starting'); + let filesStructure = (await getFilesStructure(env, user, '/', false, true)).model; + let dirs = filesStructure.directories; for (let id = 0; id < dirs.length; id++) { const dir = dirs[id]; - await download(env, user, dir.name, argv.outPath, true, null, argv.iamstupid); + await download(env, user, dir.name, argv.outPath, true, null, argv.raw, argv.dangerouslyIncludeLogsAndCache, [], false, output); } - await download(env, user, '', argv.outPath, false, 'Base.zip'); - console.log('The files in the base "files" folder is in Base.zip, each directory in "files" is in its own zip') - }) + await download(env, user, '/.', argv.outPath, false, 'Base.zip', argv.raw, argv.dangerouslyIncludeLogsAndCache, Array.from(filesStructure.files.data, f => f.name), false, output); + if (argv.raw) output.log('The files in the base "files" folder is in Base.zip, each directory in "files" is in its own zip'); + }; + + if (output.json) { + await fullExport(); + } else { + await interactiveConfirm('Are you sure you want a full export of files?', fullExport); + } + } + } else if (argv.import) { + if (argv.dirPath && argv.outPath) { + let resolvedPath = path.resolve(argv.dirPath); + if (argv.recursive) { + await processDirectory(env, user, resolvedPath, argv.outPath, resolvedPath, argv.createEmpty, true, argv.overwrite, output); + } else { + let filesInDir = getFilesInDirectory(resolvedPath); + await uploadFiles(env, user, filesInDir, argv.outPath, argv.createEmpty, argv.overwrite, output); + } + } + } else if (argv.delete) { + if (!argv.dirPath) { + throw createCommandError('A path is required for delete operations.', 400); + } + + const isFile = isFilePath(argv, argv.dirPath); + + if (argv.empty && isFile) { + throw createCommandError('--empty can only be used with directories.', 400); + } + + const shouldConfirm = !output.json; + + if (shouldConfirm) { + const action = argv.empty + ? `empty directory "${argv.dirPath}"` + : isFile + ? `delete file "${argv.dirPath}"` + : `delete directory "${argv.dirPath}"`; + + await interactiveConfirm(`Are you sure you want to ${action}?`, async () => { + await deleteRemote(env, user, argv.dirPath, isFile, argv.empty, output); + }); + } else { + await deleteRemote(env, user, argv.dirPath, isFile, argv.empty, output); } + } else if (argv.copy) { + if (!argv.dirPath) { + throw createCommandError('A source path [dirPath] is required for copy operations.', 400); + } + + await copyRemote(env, user, argv.dirPath, argv.copy, output); + } else if (argv.move) { + if (!argv.dirPath) { + throw createCommandError('A source path [dirPath] is required for move operations.', 400); + } + + await moveRemote(env, user, argv.dirPath, argv.move, argv.overwrite, output); } } -function resolveTree(dirs, indentLevel, parentHasFiles) { +function getFilesInDirectory(dirPath) { + return fs.statSync(dirPath).isFile() ? [dirPath] : fs.readdirSync(dirPath) + .map(file => path.join(dirPath, file)) + .filter(file => fs.statSync(file).isFile()); +} + +async function processDirectory(env, user, dirPath, outPath, originalDir, createEmpty, isRoot = false, overwrite = false, output) { + let filesInDir = getFilesInDirectory(dirPath); + if (filesInDir.length > 0) + await uploadFiles(env, user, filesInDir, isRoot ? outPath : path.join(outPath, path.basename(dirPath)), createEmpty, overwrite, output); + + const subDirectories = fs.readdirSync(dirPath) + .map(subDir => path.join(dirPath, subDir)) + .filter(subDir => fs.statSync(subDir).isDirectory()); + for (let subDir of subDirectories) { + await processDirectory(env, user, subDir, isRoot ? outPath : path.join(outPath, path.basename(dirPath)), originalDir, createEmpty, false, overwrite, output); + } +} + +function resolveTree(dirs, indentLevel, parentHasFiles, output) { let end = `└──` let mid = `├──` for (let id = 0; id < dirs.length; id++) { @@ -93,90 +257,487 @@ function resolveTree(dirs, indentLevel, parentHasFiles) { let indentPipe = true; if (dirs.length == 1) { if (parentHasFiles) { - console.log(indentLevel + mid, dir.name) + output.log(indentLevel + mid, dir.name) } else { - console.log(indentLevel + end, dir.name) + output.log(indentLevel + end, dir.name) indentPipe = false; } } else if (id != dirs.length - 1) { - console.log(indentLevel + mid, dir.name) + output.log(indentLevel + mid, dir.name) } else { if (parentHasFiles) { - console.log(indentLevel + mid, dir.name) + output.log(indentLevel + mid, dir.name) } else { - console.log(indentLevel + end, dir.name) + output.log(indentLevel + end, dir.name) indentPipe = false; } } let hasFiles = dir.files?.data && dir.files?.data.length !== 0; if (indentPipe) { - resolveTree(dir.directories ?? [], indentLevel + '│\t', hasFiles); - resolveTree(dir.files?.data ?? [], indentLevel + '│\t', false); + resolveTree(dir.directories ?? [], indentLevel + '│\t', hasFiles, output); + resolveTree(dir.files?.data ?? [], indentLevel + '│\t', false, output); } else { - resolveTree(dir.directories ?? [], indentLevel + '\t', hasFiles); - resolveTree(dir.files?.data ?? [], indentLevel + '\t', false); + resolveTree(dir.directories ?? [], indentLevel + '\t', hasFiles, output); + resolveTree(dir.files?.data ?? [], indentLevel + '\t', false, output); } } } -async function download(env, user, dirPath, outPath, recursive, outname, iamstupid) { - let endpoint; - if (recursive) { - endpoint = 'DirectoryDownload'; - } else { - endpoint = 'FileDownload' - } +async function download(env, user, dirPath, outPath, recursive, outname, raw, dangerouslyIncludeLogsAndCache, fileNames, singleFileMode, output) { let excludeDirectories = ''; - if (!iamstupid) { - excludeDirectories = '&Command.ExcludeDirectories=system/log'; + if (!dangerouslyIncludeLogsAndCache) { + excludeDirectories = 'system/log'; if (dirPath === 'cache.net') { return; } } - console.log('Downloading', dirPath === '' ? 'Base' : dirPath, 'Recursive=' + recursive); + const { endpoint, data } = prepareDownloadCommandData(dirPath, excludeDirectories, fileNames, recursive, singleFileMode); - let filename; - fetch(`https://${env.host}/Admin/Api/${endpoint}?Command.DirectoryPath=${dirPath ?? ''}${excludeDirectories}`, { + displayDownloadMessage(dirPath, fileNames, recursive, singleFileMode, output); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${endpoint}`, { method: 'POST', + body: JSON.stringify(data), headers: { - 'Authorization': `Bearer ${user.apiKey}` + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' }, - agent: agent - }).then((res) => { - const header = res.headers.get('Content-Disposition'); - const parts = header?.split(';'); - if (!parts) { - console.log(`No files found in directory '${dirPath}', if you want to download all folders recursively include the -r flag`); - return; + agent: getAgent(env.protocol) + }); + + const filename = outname || tryGetFileNameFromResponse(res, dirPath, output.verbose); + if (!filename) return; + + const filePath = path.resolve(`${path.resolve(outPath)}/${filename}`) + const updater = output.json ? null : createThrottledStatusUpdater(); + + await downloadWithProgress(res, filePath, { + onData: (received) => { + if (updater) { + updater.update(`Received:\t${formatBytes(received)}`); + } } - filename = parts[1].split('=')[1]; - if (outname) filename = outname; - return res; - }).then(async (res) => { - if (!filename) return; - const fileStream = fs.createWriteStream(path.resolve(`${path.resolve(outPath)}/${filename}`)); - await new Promise((resolve, reject) => { - res.body.pipe(fileStream); - res.body.on("error", reject); - fileStream.on("finish", resolve); - }); - console.log(`Finished downloading`, dirPath === '' ? '.' : dirPath, 'Recursive=' + recursive); - return res; + }); + + if (updater) { + updater.stop(); + } + + if (singleFileMode) { + output.log(`Successfully downloaded: ${filename}`); + } else { + output.log(`Finished downloading`, dirPath === '/.' ? '.' : dirPath, 'Recursive=' + recursive); + } + + output.addData({ + type: 'download', + directoryPath: dirPath, + filename, + outPath: path.resolve(outPath), + recursive, + raw + }); + + await extractArchive(filename, filePath, outPath, raw, output); +} + +export function prepareDownloadCommandData(directoryPath, excludeDirectories, fileNames, recursive, singleFileMode) { + const data = { + 'DirectoryPath': directoryPath ?? '/', + 'ExcludeDirectories': [excludeDirectories], + }; + + if (recursive && !singleFileMode) { + return { endpoint: 'DirectoryDownload', data }; + } + + data['Ids'] = fileNames; + return { endpoint: 'FileDownload', data }; +} + +function displayDownloadMessage(directoryPath, fileNames, recursive, singleFileMode, output) { + if (singleFileMode) { + const fileName = path.basename(fileNames[0] || 'unknown'); + output.log('Downloading file: ' + fileName); + + return; + } + + const directoryPathDisplayName = directoryPath === '/.' + ? 'Base' + : directoryPath; + + output.log('Downloading', directoryPathDisplayName, 'Recursive=' + recursive); +} + +async function extractArchive(filename, filePath, outPath, raw, output) { + if (raw) { + return; + } + + output.log(`\nExtracting ${filename} to ${outPath}`); + let destinationFilename = filename.replace('.zip', ''); + if (destinationFilename === 'Base') + destinationFilename = ''; + + const destinationPath = `${path.resolve(outPath)}/${destinationFilename}`; + const updater = output.json ? null : createThrottledStatusUpdater(); + + await extractWithProgress(filePath, destinationPath, { + onEntry: (processedEntries, totalEntries, percent) => { + if (updater) { + updater.update(`Extracted:\t${processedEntries} of ${totalEntries} files (${percent}%)`); + } + } + }); + + if (updater) { + updater.stop(); + } + output.log(`Finished extracting ${filename} to ${outPath}\n`); + + await fs.promises.unlink(filePath).catch(err => { + output.verboseLog(`Warning: Failed to delete temporary archive ${filePath}: ${err.message}`); }); } async function getFilesStructure(env, user, dirPath, recursive, includeFiles) { - let res = await fetch(`https://${env.host}/Admin/Api/DirectoryAll?DirectoryPath=${dirPath ?? ''}&recursive=${recursive ?? 'false'}&includeFiles=${includeFiles ?? 'false'}`, { + let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/DirectoryAll?DirectoryPath=${dirPath ?? '/'}&recursive=${recursive ?? 'false'}&includeFiles=${includeFiles ?? 'false'}`, { method: 'GET', headers: { 'Authorization': `Bearer ${user.apiKey}` }, - agent: agent + agent: getAgent(env.protocol) }); if (res.ok) { return await res.json(); } else { - console.log(res); + throw createCommandError('Unable to fetch file structure.', res.status, await parseJsonSafe(res)); + } +} + +async function deleteRemote(env, user, remotePath, isFile, empty, output) { + let endpoint; + let mode; + let data; + + if (isFile) { + endpoint = 'FileDelete'; + mode = 'file'; + const parentDir = path.posix.dirname(remotePath); + data = { + DirectoryPath: parentDir === '.' ? '/' : parentDir, + Ids: [remotePath] + }; + } else if (empty) { + endpoint = 'DirectoryEmpty'; + mode = 'empty'; + data = { Path: remotePath }; + } else { + endpoint = 'DirectoryDelete'; + mode = 'directory'; + data = { Path: remotePath }; + } + + output.log(`${mode === 'empty' ? 'Emptying' : 'Deleting'} ${mode === 'file' ? 'file' : 'directory'}: ${remotePath}`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to ${mode === 'empty' ? 'empty' : 'delete'} "${remotePath}".`, res.status, await parseJsonSafe(res)); + } + + const body = await parseJsonSafe(res); + + output.setStatus(200); + output.addData({ + type: 'delete', + path: remotePath, + mode, + response: body + }); + + output.log(`Successfully ${mode === 'empty' ? 'emptied' : 'deleted'}: ${remotePath}`); +} + +async function copyRemote(env, user, sourcePath, destination, output) { + output.log(`Copying ${sourcePath} to ${destination}`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/AssetCopy`, { + method: 'POST', + body: JSON.stringify({ + Destination: destination, + Ids: [sourcePath] + }), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to copy "${sourcePath}" to "${destination}".`, res.status, await parseJsonSafe(res)); + } + + const body = await parseJsonSafe(res); + + output.setStatus(200); + output.addData({ + type: 'copy', + sourcePath, + destination, + response: body + }); + + output.log(`Successfully copied ${sourcePath} to ${destination}`); +} + +async function moveRemote(env, user, sourcePath, destination, overwrite, output) { + output.log(`Moving ${sourcePath} to ${destination}`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/AssetMove`, { + method: 'POST', + body: JSON.stringify({ + Destination: destination, + Overwrite: Boolean(overwrite), + Ids: [sourcePath] + }), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to move "${sourcePath}" to "${destination}".`, res.status, await parseJsonSafe(res)); } -} \ No newline at end of file + + const body = await parseJsonSafe(res); + + output.setStatus(200); + output.addData({ + type: 'move', + sourcePath, + destination, + overwrite: Boolean(overwrite), + response: body + }); + + output.log(`Successfully moved ${sourcePath} to ${destination}`); +} + +export async function uploadFiles(env, user, localFilePaths, destinationPath, createEmpty = false, overwrite = false, output = createFilesOutput({})) { + output.log('Uploading files') + + const chunkSize = 300; + const chunks = []; + + for (let i = 0; i < localFilePaths.length; i += chunkSize) { + chunks.push(localFilePaths.slice(i, i + chunkSize)); + } + + output.mergeMeta((meta) => ({ + filesProcessed: (meta.filesProcessed || 0) + localFilePaths.length, + chunks: (meta.chunks || 0) + chunks.length + })); + + for (let i = 0; i < chunks.length; i++) { + output.log(`Uploading chunk ${i + 1} of ${chunks.length}`); + + const chunk = chunks[i]; + const body = await uploadChunk(env, user, chunk, destinationPath, createEmpty, overwrite, output); + output.addData({ + type: 'upload', + destinationPath, + files: chunk.map(filePath => path.resolve(filePath)), + response: body + }); + + output.log(`Finished uploading chunk ${i + 1} of ${chunks.length}`); + } + + output.log(`Finished uploading files. Total files: ${localFilePaths.length}, total chunks: ${chunks.length}`); +} + +async function uploadChunk(env, user, filePathsChunk, destinationPath, createEmpty, overwrite, output) { + const form = new FormData(); + form.append('path', destinationPath); + form.append('skipExistingFiles', String(!overwrite)); + form.append('allowOverwrite', String(overwrite)); + + filePathsChunk.forEach(fileToUpload => { + output.log(`${fileToUpload}`) + form.append('files', fs.createReadStream(path.resolve(fileToUpload))); + }); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/Upload?` + new URLSearchParams({ "createEmptyFiles": createEmpty, "createMissingDirectories": true }), { + method: 'POST', + body: form, + headers: { + 'Authorization': `Bearer ${user.apiKey}` + }, + agent: getAgent(env.protocol) + }); + + if (res.ok) { + return await res.json(); + } + else { + throw createCommandError('File upload failed.', res.status, await parseJsonSafe(res)); + } +} + +export function resolveUploadOutput(output) { + const response = output?.response ?? {}; + response.meta = response.meta ?? {}; + + return { + structured: Boolean(output), + response, + log: typeof output?.log === 'function' + ? output.log.bind(output) + : (...args) => console.log(...args), + addData: typeof output?.addData === 'function' + ? output.addData.bind(output) + : () => { }, + mergeMeta: typeof output?.mergeMeta === 'function' + ? output.mergeMeta.bind(output) + : (meta) => { + response.meta = { + ...response.meta, + ...meta + }; + } + }; +} + +function createUploadError(message, status, details = null) { + const error = new Error(message); + error.status = status; + error.details = details; + return error; +} + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; + } +} + +export function resolveFilePath(filePath) { + let p = path.parse(path.resolve(filePath)) + let regex = wildcardToRegExp(p.base); + let resolvedPath = fs.readdirSync(p.dir).filter((allFilesPaths) => allFilesPaths.match(regex) !== null)[0] + if (resolvedPath === undefined) { + throw createCommandError(`Could not find any files with the name ${filePath}`, 1); + } + return path.join(p.dir, resolvedPath); +} + + +export function isFilePath(argv, dirPath) { + if (argv.asFile || argv.asDirectory) { + return Boolean(argv.asFile); + } + return path.extname(dirPath) !== ''; +} + +export function wildcardToRegExp(wildcard) { + const escaped = wildcard.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); +} + +function createFilesOutput(argv) { + const response = { + ok: true, + command: 'files', + operation: getFilesOperation(argv), + status: 0, + data: [], + errors: [], + meta: {} + }; + + return { + json: argv.output === 'json' || Boolean(argv.json), + verbose: Boolean(argv.verbose), + response, + log(...args) { + if (!this.json) { + console.log(...args); + } + }, + verboseLog(...args) { + if (this.verbose && !this.json) { + console.info(...args); + } + }, + addData(entry) { + response.data.push(entry); + }, + mergeMeta(metaOrFn) { + const meta = typeof metaOrFn === 'function' ? metaOrFn(response.meta) : metaOrFn; + response.meta = { + ...response.meta, + ...meta + }; + }, + setStatus(status) { + response.status = status; + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown files command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} + +export function getFilesOperation(argv) { + if (argv.list) { + return 'list'; + } + + if (argv.export) { + return 'export'; + } + + if (argv.import) { + return 'import'; + } + + if (argv.delete) { + return 'delete'; + } + + if (argv.copy) { + return 'copy'; + } + + if (argv.move) { + return 'move'; + } + + return 'unknown'; +} diff --git a/bin/commands/files.test.js b/bin/commands/files.test.js new file mode 100644 index 0000000..2bf02b4 --- /dev/null +++ b/bin/commands/files.test.js @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { resolveFilePath, resolveUploadOutput } from './files.js'; + +test('resolveUploadOutput falls back to a console-compatible output object', () => { + const output = resolveUploadOutput(); + + assert.equal(typeof output.log, 'function'); + assert.equal(typeof output.addData, 'function'); + assert.equal(typeof output.mergeMeta, 'function'); + assert.deepEqual(output.response.meta, {}); + + output.mergeMeta({ chunks: 1, filesProcessed: 2 }); + + assert.deepEqual(output.response.meta, { + chunks: 1, + filesProcessed: 2 + }); +}); + +test('resolveUploadOutput preserves custom logging and merges meta when mergeMeta is absent', () => { + const calls = []; + const data = []; + const output = { + log: (...args) => calls.push(args), + addData: (entry) => data.push(entry), + response: { + meta: { + existing: true + } + } + }; + + const resolved = resolveUploadOutput(output); + + resolved.log('Uploading chunk 1 of 1'); + resolved.addData({ file: 'addon.nupkg' }); + resolved.mergeMeta({ chunks: 1 }); + + assert.deepEqual(calls, [[ 'Uploading chunk 1 of 1' ]]); + assert.deepEqual(data, [{ file: 'addon.nupkg' }]); + assert.deepEqual(resolved.response.meta, { + existing: true, + chunks: 1 + }); +}); + +test('resolveUploadOutput initializes response.meta for partial output objects', () => { + const resolved = resolveUploadOutput({ + log: () => {}, + response: {} + }); + + resolved.mergeMeta({ chunks: 2 }); + + assert.deepEqual(resolved.response.meta, { + chunks: 2 + }); +}); + +test('resolveFilePath throws when no matching file exists', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dw-cli-files-test-')); + + try { + assert.throws( + () => resolveFilePath(path.join(tempDir, 'missing*.nupkg')), + /Could not find any files with the name/ + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/bin/commands/folders.js b/bin/commands/folders.js new file mode 100644 index 0000000..359ecdc --- /dev/null +++ b/bin/commands/folders.js @@ -0,0 +1,292 @@ +import fetch from 'node-fetch'; +import path from 'path'; +import { setupEnv, getAgent, createCommandError } from './env.js'; +import { setupUser } from './login.js'; +import { interactiveConfirm } from '../utils.js'; +import { handleFiles } from './files.js'; + +export function foldersCommand() { + return { + command: 'folders ', + describe: 'Manages remote directories', + builder: (yargs) => { + return yargs + .positional('folderPath', { + describe: 'The remote directory path to operate on' + }) + .option('create', { + alias: 'c', + type: 'boolean', + describe: 'Creates the directory at [folderPath]' + }) + .option('rename', { + alias: 'rn', + type: 'string', + describe: 'Renames the directory at [folderPath] to the given name' + }) + .option('move', { + alias: 'm', + type: 'string', + describe: 'Moves the directory at [folderPath] to the given destination path' + }) + .option('delete', { + alias: 'd', + type: 'boolean', + describe: 'Deletes the directory at [folderPath]' + }) + .option('empty', { + type: 'boolean', + describe: 'Used with --delete, empties the directory instead of deleting it' + }) + .option('copy', { + type: 'string', + describe: 'Copies the directory at [folderPath] to the given destination path' + }) + .option('export', { + alias: 'e', + type: 'boolean', + describe: 'Exports the directory at [folderPath] to [outPath]' + }) + .option('outPath', { + alias: 'o', + type: 'string', + describe: 'Used with --export, local destination path (defaults to .)', + default: '.' + }) + .option('raw', { + type: 'boolean', + describe: 'Used with --export, keeps zip file instead of unpacking it' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) + }, + handler: async (argv) => { + const output = createFoldersOutput(argv); + + try { + await handleFolders(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } + } + }; +} + +async function handleFolders(argv, output) { + let env = await setupEnv(argv, output); + let user = await setupUser(argv, env); + + if (argv.create) { + await createFolder(env, user, argv.folderPath, output); + } else if (argv.rename) { + await renameFolder(env, user, argv.folderPath, argv.rename, output); + } else if (argv.move) { + await moveFolder(env, user, argv.folderPath, argv.move, output); + } else if (argv.delete) { + const action = argv.empty + ? `empty directory "${argv.folderPath}"` + : `delete directory "${argv.folderPath}"`; + + if (output.json) { + await deleteFolderViaFiles(env, user, argv, output); + } else { + await interactiveConfirm(`Are you sure you want to ${action}?`, async () => { + await deleteFolderViaFiles(env, user, argv, output); + }); + } + } else if (argv.copy) { + await copyFolderViaFiles(env, user, argv, output); + } else if (argv.export) { + await exportFolderViaFiles(env, user, argv, output); + } +} + +async function createFolder(env, user, folderPath, output) { + const parentPath = path.posix.dirname(folderPath); + const name = path.posix.basename(folderPath); + + output.log(`Creating directory: ${folderPath}`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/DirectorySave`, { + method: 'POST', + body: JSON.stringify({ + Name: name, + ParentPath: parentPath === '.' ? '/' : parentPath + }), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to create directory "${folderPath}".`, res.status, await parseJsonSafe(res)); + } + + const body = await parseJsonSafe(res); + + output.setStatus(0); + output.addData({ type: 'create', path: folderPath, response: body }); + output.log(`Successfully created: ${folderPath}`); +} + +async function renameFolder(env, user, folderPath, newName, output) { + const parentPath = path.posix.dirname(folderPath); + const currentName = path.posix.basename(folderPath); + + output.log(`Renaming directory "${currentName}" to "${newName}"`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/DirectorySave`, { + method: 'POST', + body: JSON.stringify({ + Name: newName, + ParentPath: parentPath === '.' ? '/' : parentPath, + CurrentName: currentName + }), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to rename "${folderPath}" to "${newName}".`, res.status, await parseJsonSafe(res)); + } + + const body = await parseJsonSafe(res); + + output.setStatus(0); + output.addData({ type: 'rename', path: folderPath, newName, response: body }); + output.log(`Successfully renamed "${currentName}" to "${newName}"`); +} + +async function moveFolder(env, user, sourcePath, destinationPath, output) { + output.log(`Moving directory "${sourcePath}" to "${destinationPath}"`); + + const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/DirectoryMove`, { + method: 'POST', + body: JSON.stringify({ + SourcePath: sourcePath, + DestinationPath: destinationPath + }), + headers: { + 'Authorization': `Bearer ${user.apiKey}`, + 'Content-Type': 'application/json' + }, + agent: getAgent(env.protocol) + }); + + if (!res.ok) { + throw createCommandError(`Failed to move "${sourcePath}" to "${destinationPath}".`, res.status, await parseJsonSafe(res)); + } + + const body = await parseJsonSafe(res); + + output.setStatus(0); + output.addData({ type: 'move', sourcePath, destinationPath, response: body }); + output.log(`Successfully moved "${sourcePath}" to "${destinationPath}"`); +} + +async function deleteFolderViaFiles(env, user, argv, output) { + await handleFiles({ + ...argv, + dirPath: argv.folderPath, + asDirectory: true, + asFile: false + }, output); +} + +async function copyFolderViaFiles(env, user, argv, output) { + await handleFiles({ + ...argv, + dirPath: argv.folderPath, + asDirectory: true, + asFile: false + }, output); +} + +async function exportFolderViaFiles(env, user, argv, output) { + await handleFiles({ + ...argv, + dirPath: argv.folderPath, + outPath: argv.outPath, + asDirectory: true, + asFile: false + }, output); +} + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; + } +} + +function createFoldersOutput(argv) { + const response = { + ok: true, + command: 'folders', + operation: getFoldersOperation(argv), + status: 0, + data: [], + errors: [], + meta: {} + }; + + return { + json: argv.output === 'json', + verbose: Boolean(argv.verbose), + response, + log(...args) { + if (!this.json) { + console.log(...args); + } + }, + verboseLog(...args) { + if (this.verbose && !this.json) { + console.info(...args); + } + }, + addData(entry) { + response.data.push(entry); + }, + mergeMeta(metaOrFn) { + const meta = typeof metaOrFn === 'function' ? metaOrFn(response.meta) : metaOrFn; + response.meta = { ...response.meta, ...meta }; + }, + setStatus(status) { + response.status = status; + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown folders command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} + +function getFoldersOperation(argv) { + if (argv.create) return 'create'; + if (argv.rename) return 'rename'; + if (argv.move) return 'move'; + if (argv.delete) return 'delete'; + if (argv.copy) return 'copy'; + if (argv.export) return 'export'; + return 'unknown'; +} diff --git a/bin/commands/install.js b/bin/commands/install.js index 70dff20..1816339 100644 --- a/bin/commands/install.js +++ b/bin/commands/install.js @@ -1,82 +1,159 @@ import fetch from 'node-fetch'; import path from 'path'; -import fs from 'fs'; -import FormData from 'form-data'; -import { Agent } from 'https'; -import { setupEnv } from './env.js'; +import { setupEnv, getAgent, createCommandError } from './env.js'; import { setupUser } from './login.js'; - -const agent = new Agent({ - rejectUnauthorized: false -}) +import { uploadFiles, resolveFilePath } from './files.js'; export function installCommand() { return { - command: 'install [filePath]', - describe: 'Installs the addin on the given path, allowed file extensions are .dll, .nupkg', + command: 'install ', + describe: 'Installs the addin on the given path, allowed file extensions are .dll, .nupkg', builder: (yargs) => { return yargs - .positional('filePath', { - describe: 'Path to the file to install' - }) + .positional('filePath', { + describe: 'Path to the file to install' + }) + .option('queue', { + alias: 'q', + type: 'boolean', + describe: 'Queues the install for next Dynamicweb recycle' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) }, - handler: (argv) => { - if (argv.verbose) console.info(`Installing file located at :${argv.filePath}`) - handleInstall(argv) + handler: async (argv) => { + const output = createInstallOutput(argv); + + try { + output.verboseLog(`Installing file located at: ${argv.filePath}`); + await handleInstall(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } } } } -async function handleInstall(argv) { - let env = await setupEnv(argv); +async function handleInstall(argv, output) { + let env = await setupEnv(argv, output); let user = await setupUser(argv, env); - let resolvedPath = path.resolve(argv.filePath) - await uploadFile(env, user, resolvedPath); - await installAddin(env, user, resolvedPath) + let resolvedPath = resolveFilePath(argv.filePath); + await uploadFiles(env, user, [resolvedPath], 'System/AddIns/Local', false, true, output); + await installAddin(env, user, resolvedPath, argv.queue, output); } -async function uploadFile(env, user, resolvedPath) { - console.log('Uploading file') - let files = new FormData(); - files.append('files', fs.createReadStream(resolvedPath)); - let res = await fetch(`https://${env.host}/Admin/Api/FileUpload?Command.Path=System/AddIns/Local`, { - method: 'POST', - body: files, - headers: { - 'Authorization': `Bearer ${user.apiKey}` - }, - agent: agent - }); - if (res.ok) { - if (env.verbose) console.log(await res.json()) - console.log(`File uploaded`) +async function installAddin(env, user, resolvedPath, queue, output) { + output.log('Installing addin'); + let filename = path.basename(resolvedPath); + let data = { + 'Queue': queue, + 'Ids': [ + `${filename.substring(0, filename.lastIndexOf('.')) || filename}|${path.extname(resolvedPath)}` + ] } - else { - console.log(res) - return; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + + let res; + try { + res = await fetch(`${env.protocol}://${env.host}/Admin/Api/AddinInstall`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.apiKey}` + }, + agent: getAgent(env.protocol), + signal: controller.signal + }); + } catch (err) { + if (err.name === 'AbortError') { + throw createCommandError('Addin installation request timed out.', 408); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + + if (res.ok) { + const body = await parseJsonSafe(res); + output.verboseLog(body); + output.addData({ + type: 'install', + filePath: resolvedPath, + filename, + queued: Boolean(queue), + response: body + }); + output.log('Addin installed'); + } else { + const body = await parseJsonSafe(res); + throw createCommandError('Addin installation failed.', res.status, body); } } -async function installAddin(env, user, resolvedPath) { - console.log('Installing addin') - let data = new URLSearchParams(); - data.append('AddinProvider', 'Dynamicweb.Marketplace.NuGet.LocalAddinProvider'); - data.append('Package', path.basename(resolvedPath)); - let res = await fetch(`https://${env.host}/Admin/Api/AddinInstall`, { - method: 'POST', - body: data, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Bearer ${user.apiKey}` +function createInstallOutput(argv) { + const response = { + ok: true, + command: 'install', + operation: argv.queue ? 'queue' : 'install', + status: 0, + data: [], + errors: [], + meta: { + filePath: argv.filePath + } + }; + + return { + json: argv.output === 'json', + response, + log(...args) { + if (!this.json) { + console.log(...args); + } + }, + verboseLog(...args) { + if (argv.verbose && !this.json) { + console.info(...args); + } + }, + addData(entry) { + response.data.push(entry); }, - agent: agent - }); + mergeMeta(metaOrFn) { + const meta = typeof metaOrFn === 'function' ? metaOrFn(response.meta) : metaOrFn; + response.meta = { + ...response.meta, + ...meta + }; + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown install command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} - if (res.ok) { - if (env.verbose) console.log(await res.json()) - console.log(`Addin installed`) - } - else { - console.log(res) + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; } -} \ No newline at end of file +} diff --git a/bin/commands/install.test.js b/bin/commands/install.test.js new file mode 100644 index 0000000..74f74cb --- /dev/null +++ b/bin/commands/install.test.js @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createInstallOutput } from './install.js'; + +test('createInstallOutput suppresses regular logs in json mode and emits the final envelope', () => { + const logCalls = []; + const infoCalls = []; + const originalLog = console.log; + const originalInfo = console.info; + + console.log = (...args) => logCalls.push(args); + console.info = (...args) => infoCalls.push(args); + + try { + const output = createInstallOutput({ + output: 'json', + queue: true, + verbose: true + }); + + output.log('hidden'); + output.verboseLog('hidden verbose'); + output.addData({ type: 'install', filename: 'addon.nupkg' }); + output.mergeMeta({ resolvedPath: '/tmp/addon.nupkg' }); + output.finish(); + + assert.deepEqual(infoCalls, []); + assert.equal(logCalls.length, 1); + + const rendered = JSON.parse(logCalls[0][0]); + assert.deepEqual(rendered, { + ok: true, + command: 'install', + operation: 'install', + status: 0, + data: [{ type: 'install', filename: 'addon.nupkg' }], + errors: [], + meta: { + queued: true, + resolvedPath: '/tmp/addon.nupkg' + } + }); + } finally { + console.log = originalLog; + console.info = originalInfo; + } +}); diff --git a/bin/commands/login.js b/bin/commands/login.js index 58d8756..2c737f1 100644 --- a/bin/commands/login.js +++ b/bin/commands/login.js @@ -1,12 +1,11 @@ import fetch from 'node-fetch'; -import { interactiveEnv } from './env.js' +import { interactiveEnv, getAgent, isJsonOutput, createCommandError } from './env.js' import { updateConfig, getConfig } from './config.js'; -import yargsInteractive from 'yargs-interactive'; -import { Agent } from 'https'; +import { input, password } from '@inquirer/prompts'; -const agent = new Agent({ - rejectUnauthorized: false -}) +const DEFAULT_OAUTH_TOKEN_PATH = '/Admin/OAuth/token'; +const DEFAULT_CLIENT_ID_ENV = 'DW_CLIENT_ID'; +const DEFAULT_CLIENT_SECRET_ENV = 'DW_CLIENT_SECRET'; export function loginCommand() { return { @@ -17,122 +16,212 @@ export function loginCommand() { .positional('user', { describe: 'user' }) + .option('oauth', { + type: 'boolean', + describe: 'Configures OAuth client_credentials authentication for the current environment' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing' + }) }, - handler: (argv) => handleLogin(argv) + handler: async (argv) => { + const output = createLoginOutput(argv); + + try { + await handleLogin(argv, output); + } catch (err) { + output.fail(err); + process.exitCode = 1; + } finally { + output.finish(); + } + } } } export async function setupUser(argv, env) { - let user; - if (env.users) { - user = env.users[argv.user] || env.users[env.current.user]; + let user = {}; + let askLogin = true; + + if (argv.apiKey) { + user.apiKey = argv.apiKey; + askLogin = false; + } + + if (!user.apiKey && shouldUseOAuth(argv, env)) { + return await authenticateWithOAuth(argv, env); + } + + if (!user.apiKey && env.users && (argv.user || env.current?.user)) { + user = env.users[argv.user] || env.users[env.current?.user]; + askLogin = false; } - if (!user) { - console.log('Current user not set, please login') + + if (askLogin && argv.host) { + throw createCommandError('Please add an --apiKey, or provide OAuth client credentials when overriding the host.'); + } + else if (askLogin) { + if (isJsonOutput(argv)) { + throw createCommandError('Current user not set, please login'); + } + + logMessage(argv, 'Current user not set, please login'); await interactiveLogin(argv, { environment: { type: 'input', default: getConfig()?.current?.env, prompt: 'never' }, - username: { + username: { type: 'input' }, - password: { + password: { type: 'password' }, interactive: { default: true } }) - user = env.users[env.current.user]; + const currentEnv = getConfig()?.current?.env; + user = getConfig()?.env?.[currentEnv]?.users?.[getConfig()?.env?.[currentEnv]?.current?.user]; } + return user; } -async function handleLogin(argv) { - argv.user ? changeUser(argv) : interactiveLogin(argv, { +async function handleLogin(argv, output) { + if (shouldUseOAuth(argv, getCurrentEnv(argv))) { + if (isJsonOutput(argv)) { + output.addData(await nonInteractiveOAuthLogin(argv)); + } else { + output.addData(await interactiveOAuthLogin(argv, output)); + } + } else if (argv.user) { + output.addData(await changeUser(argv)); + } else { + if (isJsonOutput(argv)) { + throw createCommandError('Interactive login is not supported with --output json. Use --apiKey, or configure OAuth with --oauth --clientIdEnv/--clientSecretEnv.'); + } + output.addData(await interactiveLogin(argv, { environment: { type: 'input', default: getConfig()?.current?.env || 'dev', prompt: 'if-no-arg' }, - username: { + username: { type: 'input' }, - password: { + password: { type: 'password' }, interactive: { default: true } - }) -} - -export async function interactiveLogin(argv, options) { - if (argv.verbose) console.info('Now logging in') - await yargsInteractive() - .interactive(options) - .then(async (result) => { - if (!getConfig().env || !getConfig().env[result.environment] || !getConfig().env[result.environment].host) { - if (!argv.host) - console.log(`The environment specified is missing parameters, please specify them`) - await interactiveEnv(argv, { - environment: { - type: 'input', - default: result.environment, - prompt: 'never' - }, - host: { - type: 'input', - prompt: 'if-no-arg' - }, - interactive: { - default: true - } - }) - } - await loginInteractive(result); + }, output)) + } +} + +export async function interactiveLogin(argv, options, output) { + verboseLog(argv, 'Now logging in'); + const result = {}; + for (const [key, config] of Object.entries(options)) { + if (key === 'interactive') continue; + if (config.prompt === 'never') { + result[key] = config.default; + continue; + } + const promptFn = config.type === 'password' ? password : input; + result[key] = await promptFn({ + message: config.describe || key, + default: config.default, + mask: config.type === 'password' ? '*' : undefined }); + } + if (!getConfig().env || !getConfig().env[result.environment] || !getConfig().env[result.environment].host || !getConfig().env[result.environment].protocol) { + if (argv.host) { + ensureEnvironmentFromArgs(result.environment, argv); + } else { + logMessage(argv, `The environment specified is missing parameters, please specify them`); + await interactiveEnv(argv, { + environment: { + type: 'input', + default: result.environment, + prompt: 'never' + }, + host: { + describe: 'Enter your host including protocol, i.e "https://yourHost.com":', + type: 'input', + prompt: 'always' + }, + interactive: { + default: true + } + }, output) + } + } + return await loginInteractive(result, argv.verbose, argv); } -async function loginInteractive(result) { - var token = await login(result.username, result.password, result.environment); - var apiKey = await getApiKey(token, result.environment) +async function loginInteractive(result, verbose, argv) { + var protocol = getConfig().env[result.environment].protocol; + var token = await login(result.username, result.password, result.environment, protocol, verbose); + if (!token) { + throw createCommandError(`Could not fetch a login token for user ${result.username}.`); + } + var apiKey = await getApiKey(token, result.environment, protocol, verbose) + if (!apiKey) { + throw createCommandError(`Could not create an API Key for the logged in user ${result.username}.`); + } getConfig().env = getConfig().env || {}; getConfig().env[result.environment].users = getConfig().env[result.environment].users || {}; getConfig().env[result.environment].users[result.username] = getConfig().env[result.environment].users[result.username] || {}; getConfig().env[result.environment].users[result.username].apiKey = apiKey; getConfig().env[result.environment].current = getConfig().env[result.environment].current || {}; getConfig().env[result.environment].current.user = result.username; + getConfig().env[result.environment].current.authType = 'user'; + logMessage(argv, "You're now logged in as " + result.username); updateConfig(); + + return { + environment: result.environment, + username: result.username, + apiKey, + host: getConfig().env[result.environment].host, + protocol + }; } -async function login(username, password, env) { +async function login(username, password, env, protocol, verbose) { let data = new URLSearchParams(); data.append('Username', username); data.append('Password', password); - var res = await fetch(`https://${getConfig().env[env].host}/Admin/Authentication/Login`, { + var res = await fetch(`${protocol}://${getConfig().env[env].host}/Admin/Authentication/Login`, { method: 'POST', body: data, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - agent: agent + agent: getAgent(protocol), + redirect: "manual" }); - if (res.ok) { + if (res.ok || res.status == 302) { let user = parseCookies(res.headers.get('set-cookie')).user; - return await getToken(user, env) + if (!user) return; + return await getToken(user, env, protocol, verbose) } else { - console.log(res) + if (verbose) console.info(res) + throw createCommandError(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution.`, res.status) } } -function parseCookies (cookieHeader) { +export function parseCookies (cookieHeader) { const list = {}; - if (!cookieHeader) return list; + if (!cookieHeader) { + return list; + } cookieHeader.replace('httponly, ', '').replace('Dynamicweb.Admin', 'user').split(`;`).forEach(cookie => { let [ name, ...rest] = cookie.split(`=`); @@ -146,38 +235,343 @@ function parseCookies (cookieHeader) { return list; } -async function getToken(user, env) { - var res = await fetch(`https://${getConfig().env[env].host}/Admin/Authentication/Token`, { +async function getToken(user, env, protocol, verbose) { + var res = await fetch(`${protocol}://${getConfig().env[env].host}/Admin/Authentication/Token`, { method: 'GET', headers: { 'cookie': `Dynamicweb.Admin=${user}` }, - agent: agent + agent: getAgent(protocol) }); if (res.ok) { return (await res.json()).token } + else { + if (verbose) console.info(res) + throw createCommandError(`Could not fetch the token for the logged in user ${user}, please verify its a valid user in your Dynamicweb solution.`, res.status) + } } -async function getApiKey(token, env) { - let data = new URLSearchParams(); - data.append('Name', 'addin'); - data.append('Prefix', 'addin'); - data.append('Description', 'Auto-generated ApiKey by DW CLI'); - var res = await fetch(`https://${getConfig().env[env].host}/Admin/Api/ApiKeySave`, { +async function getApiKey(token, env, protocol, verbose) { + let data = { + 'Name': 'DW CLI', + 'Prefix': 'CLI', + 'Description': 'Auto-generated ApiKey by DW CLI' + }; + var res = await fetch(`${protocol}://${getConfig().env[env].host}/Admin/Api/ApiKeySave`, { method: 'POST', - body: data, + body: JSON.stringify( { 'model': data } ), headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - agent: agent + agent: getAgent(protocol) }); if (res.ok) { return (await res.json()).message } else { - console.log(res) + if (verbose) console.info(res) + throw createCommandError(`Could not create an API Key for the logged in user, please verify its a valid user in your Dynamicweb solution.`, res.status) + } +} + +async function changeUser(argv) { + if (!getConfig().current?.env || !getConfig().env?.[getConfig().current.env]) { + throw createCommandError('Current environment not set, please set it before changing user.'); + } + + getConfig().env[getConfig().current.env].current = getConfig().env[getConfig().current.env].current || {}; + getConfig().env[getConfig().current.env].current.user = argv.user; + getConfig().env[getConfig().current.env].current.authType = 'user'; + updateConfig(); + logMessage(argv, `You're now logged in as ${getConfig().env[getConfig().current.env].current.user}`); + + return { + environment: getConfig().current.env, + username: getConfig().env[getConfig().current.env].current.user + }; +} + +async function interactiveOAuthLogin(argv, output) { + verboseLog(argv, 'Configuring OAuth client credentials authentication'); + + const currentEnvName = getConfig()?.current?.env || 'dev'; + const environment = await input({ + message: 'environment', + default: currentEnvName + }); + const existingEnv = getConfig()?.env?.[environment] || {}; + const existingAuth = existingEnv.auth || {}; + + const result = { + environment, + clientIdEnv: argv.clientIdEnv || existingAuth.clientIdEnv || DEFAULT_CLIENT_ID_ENV, + clientSecretEnv: argv.clientSecretEnv || existingAuth.clientSecretEnv || DEFAULT_CLIENT_SECRET_ENV + }; + + if (!argv.clientIdEnv) { + result.clientIdEnv = await input({ + message: 'clientIdEnv', + default: result.clientIdEnv + }); + } + + if (!argv.clientSecretEnv) { + result.clientSecretEnv = await input({ + message: 'clientSecretEnv', + default: result.clientSecretEnv + }); + } + + if (argv.host) { + ensureEnvironmentFromArgs(result.environment, argv); + } else if (!getConfig().env || !getConfig().env[result.environment] || !getConfig().env[result.environment].host || !getConfig().env[result.environment].protocol) { + logMessage(argv, 'The environment specified is missing parameters, please specify them'); + await interactiveEnv(argv, { + environment: { + type: 'input', + default: result.environment, + prompt: 'never' + }, + host: { + describe: 'Enter your host including protocol, i.e "https://yourHost.com":', + type: 'input', + prompt: 'always' + }, + interactive: { + default: true + } + }, output); + } + + const oauthResult = await finalizeOAuthLogin(result.environment, result.clientIdEnv, result.clientSecretEnv, argv); + + logMessage(argv, `OAuth authentication is now configured for ${result.environment}`); + + return oauthResult; +} + +async function nonInteractiveOAuthLogin(argv) { + verboseLog(argv, 'Configuring OAuth client credentials authentication (non-interactive)'); + + let environment = getConfig()?.current?.env; + + if (!environment && argv.host) { + environment = new URL(`https://${argv.host.replace(/^https?:\/\//, '')}`).hostname.split('.')[0] || 'default'; + } + + if (!environment) { + throw createCommandError('No environment set. Configure one with "dw env" first, or pass --host.'); + } + + if (argv.host) { + ensureEnvironmentFromArgs(environment, argv); + } else if (!getConfig().env?.[environment]?.host) { + throw createCommandError(`Environment "${environment}" has no host configured. Pass --host or set it up with "dw env" first.`); + } + + const clientIdEnv = argv.clientIdEnv || getConfig().env?.[environment]?.auth?.clientIdEnv || DEFAULT_CLIENT_ID_ENV; + const clientSecretEnv = argv.clientSecretEnv || getConfig().env?.[environment]?.auth?.clientSecretEnv || DEFAULT_CLIENT_SECRET_ENV; + + return await finalizeOAuthLogin(environment, clientIdEnv, clientSecretEnv, argv); +} + +async function finalizeOAuthLogin(environment, clientIdEnv, clientSecretEnv, argv) { + const env = getConfig().env?.[environment]; + if (!env) { + throw createCommandError(`Environment "${environment}" is not configured. Run "dw env" first or pass --host.`); + } + const oauthConfig = resolveOAuthConfig({ + ...argv, + clientIdEnv, + clientSecretEnv, + oauth: true + }, env); + + const tokenResult = await fetchOAuthToken(env, oauthConfig, argv.verbose); + + getConfig().current = getConfig().current || {}; + getConfig().current.env = environment; + env.auth = { + type: 'oauth_client_credentials', + clientIdEnv, + clientSecretEnv + }; + env.current = env.current || {}; + env.current.authType = 'oauth_client_credentials'; + delete env.current.user; + updateConfig(); + + return { + environment, + authType: 'oauth_client_credentials', + clientIdEnv, + clientSecretEnv, + expires: tokenResult.expires || null + }; +} + +async function authenticateWithOAuth(argv, env) { + const oauthConfig = resolveOAuthConfig(argv, env, true); + const tokenResult = await fetchOAuthToken(env, oauthConfig, argv.verbose); + + return { + apiKey: tokenResult.token, + authType: 'oauth_client_credentials', + expires: tokenResult.expires || null + }; +} + +export function shouldUseOAuth(argv, env = {}) { + if (argv.auth === 'user') { + return false; + } + + if (argv.oauth || argv.auth === 'oauth') { + return true; + } + + if (argv.clientId || argv.clientSecret || argv.clientIdEnv || argv.clientSecretEnv) { + return true; + } + + if (env?.current?.authType) { + return env.current.authType === 'oauth_client_credentials'; + } + + return env?.auth?.type === 'oauth_client_credentials'; +} + +export function resolveOAuthConfig(argv, env = {}, requireCredentials = true) { + const authConfig = env?.auth || {}; + const clientIdEnv = argv.clientIdEnv || authConfig.clientIdEnv || DEFAULT_CLIENT_ID_ENV; + const clientSecretEnv = argv.clientSecretEnv || authConfig.clientSecretEnv || DEFAULT_CLIENT_SECRET_ENV; + const clientId = argv.clientId || process.env[clientIdEnv]; + const clientSecret = argv.clientSecret || process.env[clientSecretEnv]; + + if (requireCredentials) { + if (!clientId) { + throw createCommandError(`OAuth client ID not found. Set --clientId or export ${clientIdEnv}.`); + } + + if (!clientSecret) { + throw createCommandError(`OAuth client secret not found. Set --clientSecret or export ${clientSecretEnv}.`); + } + } + + return { + clientId, + clientSecret, + clientIdEnv, + clientSecretEnv + }; +} + +async function fetchOAuthToken(env, oauthConfig, verbose) { + const res = await fetch(`${env.protocol}://${env.host}${DEFAULT_OAUTH_TOKEN_PATH}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret + }), + agent: getAgent(env.protocol) + }); + + const body = await parseJsonSafe(res); + + if (!res.ok) { + if (verbose) { + console.info(res); + } + + throw createCommandError(`OAuth token request failed at ${DEFAULT_OAUTH_TOKEN_PATH}.`, res.status, body); + } + + const token = body?.token || body?.Token; + const expires = body?.expires || body?.Expires || null; + + if (!token) { + throw createCommandError('OAuth token response did not include a token.', res.status, body); + } + + return { token, expires }; +} + +function getCurrentEnv(argv) { + if (argv.host) { + return { + host: argv.host, + protocol: argv.protocol || 'https' + }; + } + + return getConfig()?.env?.[getConfig()?.current?.env] || {}; +} + +function ensureEnvironmentFromArgs(environment, argv) { + getConfig().env = getConfig().env || {}; + getConfig().env[environment] = getConfig().env[environment] || {}; + getConfig().env[environment].host = argv.host; + getConfig().env[environment].protocol = argv.protocol || 'https'; + getConfig().current = getConfig().current || {}; + getConfig().current.env = environment; + updateConfig(); +} + +function logMessage(argv, ...args) { + if (!isJsonOutput(argv)) { + console.log(...args); } -} \ No newline at end of file +} + +function verboseLog(argv, ...args) { + if (argv?.verbose && !isJsonOutput(argv)) { + console.info(...args); + } +} + +function createLoginOutput(argv) { + const response = { + ok: true, + command: 'login', + operation: shouldUseOAuth(argv, getCurrentEnv(argv)) ? 'oauth-login' : argv.user ? 'select-user' : 'login', + status: 0, + data: [], + errors: [], + meta: {} + }; + + return { + json: isJsonOutput(argv), + addData(entry) { + response.data.push(entry); + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown login command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; + } +} diff --git a/bin/commands/query.js b/bin/commands/query.js new file mode 100644 index 0000000..5aa7e3c --- /dev/null +++ b/bin/commands/query.js @@ -0,0 +1,192 @@ +import fetch from 'node-fetch'; +import { setupEnv, getAgent, createCommandError } from './env.js'; +import { setupUser } from './login.js'; +import { input } from '@inquirer/prompts'; + +const exclude = ['_', '$0', 'query', 'list', 'i', 'l', 'interactive', 'verbose', 'v', 'host', 'protocol', 'apiKey', 'env', 'output', 'auth', 'clientId', 'clientSecret', 'clientIdEnv', 'clientSecretEnv', 'oauth'] + +export function queryCommand() { + return { + command: 'query [query]', + describe: 'Runs the given query', + builder: (yargs) => { + return yargs + .positional('query', { + describe: 'The query to execute' + }) + .option('list', { + alias: 'l', + describe: 'Lists all the properties for the query' + }) + .option('interactive', { + alias: 'i', + describe: 'Runs in interactive mode to ask for query parameters one by one' + }) + .option('output', { + choices: ['json'], + describe: 'Outputs a single JSON response for automation-friendly parsing', + conflicts: 'interactive' + }) + }, + handler: async (argv) => { + const output = createQueryOutput(argv); + + try { + output.verboseLog(`Running query ${argv.query}`); + await handleQuery(argv, output); + } catch (err) { + output.fail(err); + if (!output.json) { + console.error(err.stack || err.message || String(err)); + } + process.exitCode = 1; + } finally { + output.finish(); + } + } + } +} + +async function handleQuery(argv, output) { + let env = await setupEnv(argv, output); + let user = await setupUser(argv, env); + if (argv.list) { + const properties = await getProperties(env, user, argv.query); + output.addData(properties); + output.log(properties); + } else { + let response = await runQuery(env, user, argv.query, await getQueryParams(env, user, argv, output)); + output.addData(response); + output.log(response); + } +} + +async function getProperties(env, user, query) { + let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/QueryByName?name=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.apiKey}` + }, + agent: getAgent(env.protocol) + }) + if (res.ok) { + let body = await res.json() + if (body?.model?.properties?.groups === undefined) { + throw createCommandError('Unable to fetch query parameters.', res.status, body); + } + return extractQueryPropertyPrompts(body); + } + + throw createCommandError('Unable to fetch query parameters.', res.status, await parseJsonSafe(res)); +} + +export async function getQueryParams(env, user, argv, output, deps = {}) { + let params = {} + const getPropertiesFn = deps.getPropertiesFn || getProperties; + const promptFn = deps.promptFn || input; + if (argv.interactive) { + let properties = await getPropertiesFn(env, user, argv.query); + output.log('The following properties will be requested:') + output.log(properties) + params = await buildInteractiveQueryParams(properties, promptFn); + } else { + params = buildQueryParamsFromArgv(argv); + } + return params +} + +export function extractQueryPropertyPrompts(body) { + const fields = body?.model?.properties?.groups?.find(g => g.name === 'Properties')?.fields || []; + return fields.map(field => `${field.name} (${field.typeName})`); +} + +export function getFieldNameFromPropertyPrompt(prompt) { + return prompt.replace(/\s+\([^)]+\)$/, ''); +} + +export async function buildInteractiveQueryParams(properties, promptFn = input) { + const params = {}; + + for (const propertyPrompt of properties) { + const value = await promptFn({ message: propertyPrompt }); + if (value) { + params[getFieldNameFromPropertyPrompt(propertyPrompt)] = value; + } + } + + return params; +} + +export function buildQueryParamsFromArgv(argv) { + let params = {} + Object.keys(argv).filter(k => !exclude.includes(k)).forEach(k => params[k] = argv[k]) + return params +} + +async function runQuery(env, user, query, params) { + let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${encodeURIComponent(query)}?` + new URLSearchParams(params), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.apiKey}` + }, + agent: getAgent(env.protocol) + }) + if (!res.ok) { + throw createCommandError(`Error when doing request ${res.url}`, res.status, await parseJsonSafe(res)); + } + return await res.json() +} + +function createQueryOutput(argv) { + const response = { + ok: true, + command: 'query', + operation: argv.list ? 'list' : 'run', + status: 0, + data: [], + errors: [], + meta: { + query: argv.query + } + }; + + return { + json: argv.output === 'json', + response, + log(value) { + if (!this.json) { + console.log(value); + } + }, + verboseLog(...args) { + if (argv.verbose && !this.json) { + console.info(...args); + } + }, + addData(entry) { + response.data.push(entry); + }, + fail(err) { + response.ok = false; + response.status = err?.status || 1; + response.errors.push({ + message: err?.message || 'Unknown query command error.', + details: err?.details ?? null + }); + }, + finish() { + if (this.json) { + console.log(JSON.stringify(response, null, 2)); + } + } + }; +} + + +async function parseJsonSafe(res) { + try { + return await res.json(); + } catch { + return null; + } +} diff --git a/bin/commands/swift.js b/bin/commands/swift.js index dffb3eb..42408cf 100644 --- a/bin/commands/swift.js +++ b/bin/commands/swift.js @@ -1,4 +1,11 @@ -import { exec } from 'child_process'; +import { execFile } from 'child_process'; +import { Agent } from 'https'; +import path from 'path'; +import fetch from 'node-fetch'; + +const agent = new Agent({ + rejectUnauthorized: false +}) export function swiftCommand() { return { @@ -7,32 +14,66 @@ export function swiftCommand() { builder: (yargs) => { return yargs .positional('outPath', { + default: '.', describe: 'Location for the swift solution' }) .option('tag', { alias: 't', - description: 'The version tag or branch to clone' + describe: 'The version tag or branch to clone' + }) + .option('list', { + alias: 'l', + describe: 'Lists all release versions' + }) + .option('nightly', { + alias: 'n', + describe: 'Will pull #HEAD, as default is latest release' }) .option('force', {}) }, - handler: (argv) => { + handler: async (argv) => { if (argv.verbose) console.info(`Downloading latest swift to :${argv.outPath}`) - handleSwift(argv) + await handleSwift(argv) } } } async function handleSwift(argv) { - if (argv.verbose) console.info(`Executing command: degit dynamicweb/swift${argv.tag ? '#' + argv.tag : ''} ${argv.force ? '--force' : ''} ${argv.outPath}`) - exec(`npx degit dynamicweb/swift${argv.tag ? '#' + argv.tag : ''} ${argv.force ? '--force' : ''} ${argv.outPath}`, (error, stdout, stderr) => { - if (error) { - console.log(`error: ${error.message}`); - return; - } - if (stderr) { - console.log(stderr); - return; - } - console.log(stdout); + if (argv.list) { + console.log(await getVersions(false)) + } else { + const repo = argv.nightly + ? 'dynamicweb/swift' + : `dynamicweb/swift#${argv.tag ? argv.tag : await getVersions(true)}`; + const args = ['degit', repo]; + if (argv.force) args.push('--force'); + args.push(path.resolve(argv.outPath)); + if (argv.verbose) console.info(`Executing: npx ${args.join(' ')}`) + execFile('npx', args, (error, stdout, stderr) => { + if (error) { + console.log(`error: ${error.message}`); + return; + } + if (stderr) { + console.log(stderr); + return; + } + console.log(stdout); + }); + } +} + +async function getVersions(latest) { + let res = await fetch(`https://api.github.com/repos/dynamicweb/swift/releases${latest ? '/latest' : ''}`, { + method: 'GET', + agent: agent }); + if (res.ok) { + let body = await res.json() + if (latest) { + return body.tag_name + } else { + return body.map(a => a.tag_name) + } + } } \ No newline at end of file diff --git a/bin/downloader.js b/bin/downloader.js new file mode 100644 index 0000000..0bf8664 --- /dev/null +++ b/bin/downloader.js @@ -0,0 +1,70 @@ +import { Response } from 'node-fetch'; +import fs from 'fs'; + +/** + * Extracts the file name from the HTTP response. + * @param {Response} res - The HTTP response object. + * @returns {string} The extracted file name. + */ +export function getFileNameFromResponse(res, dirPath) { + const header = res.headers.get('content-disposition'); + const parts = header?.split(';'); + + if (!parts) { + const msg = `No files found in directory '${dirPath}', if you want to download all folders recursively include the -r flag`; + throw new Error(msg); + } + + return parts[1].split('=')[1].replaceAll('+', ' '); +} + +/** + * Attempts to extract the file name from an HTTP response. + * If extraction fails, logs the error message to the console. + * + * @param {Object} res - The HTTP response object to extract the file name from. + * @param {string} dirPath - The directory path to use for file name resolution. + * @param {boolean} verbose - Whether to log missing download information. + * @returns {string|null} The extracted file name, or null if extraction fails. + */ +export function tryGetFileNameFromResponse(res, dirPath, verbose = false) { + try { + return getFileNameFromResponse(res, dirPath); + } catch (err) { + if (verbose) { + console.log(err.message); + } + return null; + } +} + +/** + * Downloads a file with progress reporting. + * @param {Response} res - The response from which to read the stream data. + * @param {string} filePath - The path to write the file to. + * @param {Object} options - Options for the download. + * @param {(receivedBytes: number, elapsedMillis: number, isFirstChunk: boolean) => void} options.onData - Callback invoked with each data chunk. + * @returns {Promise} Resolves when the download is complete. + */ +export function downloadWithProgress(res, filePath, options) { + const fileStream = fs.createWriteStream(filePath); + return new Promise((resolve, reject) => { + let receivedBytes = 0; + let startTime = Date.now(); + + res.body.pipe(fileStream); + res.body.on("error", reject); + fileStream.on("finish", resolve); + + res.body.on("data", chunk => { + const isFirstChunk = receivedBytes === 0; + const elapsed = Date.now() - startTime; + + receivedBytes += chunk.length; + + if (options?.onData) { + options.onData(receivedBytes, elapsed, isFirstChunk); + } + }); + }); +} diff --git a/bin/extractor.js b/bin/extractor.js new file mode 100644 index 0000000..653ae72 --- /dev/null +++ b/bin/extractor.js @@ -0,0 +1,28 @@ +import extract from 'extract-zip'; + +/** + * Extracts files from a zip archive with progress reporting. + * + * @param {string} filePath - The path to the zip file to extract. + * @param {string} destinationPath - The directory where files will be extracted. + * @param {Object} [options] - Optional settings. + * @param {(processedEntries: number, totalEntries: number, percent: string) => void} [options.onEntry] - Callback invoked on each entry extracted. + * Receives the number of processed files, total entry count, and percent complete as arguments. + * @returns {Promise} A promise that resolves when extraction is complete. + */ +export function extractWithProgress(filePath, destinationPath, options) { + let processedEntries = 0; + + return extract(filePath, { + dir: destinationPath, + onEntry: (_, zipFile) => { + processedEntries++; + + const percent = Math.floor((processedEntries / zipFile.entryCount) * 100).toFixed(0); + + if (options?.onEntry) { + options.onEntry(processedEntries, zipFile.entryCount, percent); + } + } + }, function (err) {}); +} diff --git a/bin/index.js b/bin/index.js index a9753c8..7621dc8 100644 --- a/bin/index.js +++ b/bin/index.js @@ -7,10 +7,14 @@ import { envCommand } from './commands/env.js'; import { configCommand, setupConfig, getConfig } from './commands/config.js'; import { installCommand } from './commands/install.js'; import { filesCommand } from './commands/files.js'; +import { foldersCommand } from './commands/folders.js'; import { swiftCommand } from './commands/swift.js'; import { databaseCommand } from './commands/database.js'; +import { queryCommand } from './commands/query.js'; +import { commandCommand } from './commands/command.js'; setupConfig(); +showGitBashRelativePathWarning(); yargs(hideBin(process.argv)) .scriptName('dw') @@ -20,13 +24,45 @@ yargs(hideBin(process.argv)) .command(installCommand()) .command(configCommand()) .command(filesCommand()) + .command(foldersCommand()) .command(swiftCommand()) .command(databaseCommand()) + .command(queryCommand()) + .command(commandCommand()) .option('verbose', { alias: 'v', type: 'boolean', description: 'Run with verbose logging' }) + .option('protocol', { + description: 'Set the protocol used with --host (defaults to https)' + }) + .option('host', { + description: 'Allows setting the host used, only allowed if an --apiKey or OAuth client credentials are specified' + }) + .option('apiKey', { + description: 'Allows setting the apiKey for an environmentless execution of the CLI command' + }) + .option('oauth', { + type: 'boolean', + description: 'Use OAuth client credentials authentication (shorthand for --auth oauth)' + }) + .option('auth', { + choices: ['user', 'oauth'], + description: 'Overrides the authentication mode for the command' + }) + .option('clientId', { + description: 'OAuth client ID used together with --auth oauth' + }) + .option('clientSecret', { + description: 'OAuth client secret used together with --auth oauth. WARNING: passing this on the command line can expose the secret via shell history and process listings. Prefer using --clientSecretEnv to reference a secret stored in an environment variable instead.' + }) + .option('clientIdEnv', { + description: 'Environment variable name that contains the OAuth client ID' + }) + .option('clientSecretEnv', { + description: 'Environment variable name that contains the OAuth client secret' + }) .demandCommand() .parse() @@ -35,8 +71,42 @@ function baseCommand() { command: '$0', describe: 'Shows the current env and user being used', handler: () => { - console.log(`Environment: ${getConfig()?.current?.env}`) - console.log(`User: ${getConfig()?.env[getConfig()?.current?.env]?.current?.user}`) + const cfg = getConfig(); + if (Object.keys(cfg).length === 0) { + console.log('To login to a solution use `dw login`') + return; + } + const currentEnv = cfg?.env?.[cfg?.current?.env]; + if (!currentEnv) { + console.log(`Environment '${cfg?.current?.env}' is not configured.`); + console.log('To login to a solution use `dw login`'); + return; + } + const authType = currentEnv?.current?.authType; + + console.log(`Environment: ${cfg?.current?.env}`); + if (authType === 'oauth_client_credentials') { + console.log('Authentication: OAuth client credentials'); + } else if (currentEnv?.current?.user) { + console.log(`User: ${currentEnv.current.user}`); + } + if (currentEnv.protocol) { + console.log(`Protocol: ${currentEnv.protocol}`); + } + if (currentEnv.host) { + console.log(`Host: ${currentEnv.host}`); + } } } -} \ No newline at end of file +} + +function showGitBashRelativePathWarning() { + const isGitBash = !!process.env.MSYSTEM; + const pathConversionDisabled = process.env.MSYS_NO_PATHCONV === '1'; + + if (isGitBash && !pathConversionDisabled) { + console.warn('You appear to have path conversion turned on in your shell.'); + console.warn('If you are using relative paths, this may interfere.'); + console.warn('Please see https://doc.dynamicweb.dev/documentation/fundamentals/code/CLI.html for more information.'); + } +} diff --git a/bin/utils.js b/bin/utils.js index 3a77177..967e4fc 100644 --- a/bin/utils.js +++ b/bin/utils.js @@ -1,20 +1,112 @@ -import yargsInteractive from 'yargs-interactive'; +import { confirm } from '@inquirer/prompts'; +import logUpdate from 'log-update'; + +const WRITE_THROTTLE_MS = 500; export async function interactiveConfirm(question, func) { - await yargsInteractive() - .interactive({ - confirm: { - type: 'confirm', - default: false, - describe: question, - prompt: 'always' - }, - interactive: { - default: true - } - }) - .then(async (result) => { - if (!result.confirm) return; - func() - }); -} \ No newline at end of file + const result = await confirm({ message: question, default: true }); + if (!result) return; + await func(); +} + +/** + * Creates a throttled status updater that periodically logs status messages and elapsed time. + * + * The updater allows you to update the displayed status message at any time, but throttles the actual log updates + * to the specified interval. When called with no message, the updater stops and finalizes the log output. + * + * @param {number} [intervalMs=WRITE_THROTTLE_MS] - The interval in milliseconds at which to update the log output. + * @returns {{ + * update: (message?: string) => void, + * stop: () => void + * }} An object with `update` and `stop` methods: + * - `update(message)`: Updates the status message and starts the updater if not already running. If called with no message, stops the updater. + * - `stop()`: Stops the updater and finalizes the log output. + * + * @example + * const updater = createThrottledStatusUpdater(500); + * updater.update('Processing...'); + * // ... later + * updater.update('Still working...'); + * // ... when done + * updater.update(); // or updater.stop(); + */ +export function createThrottledStatusUpdater(intervalMs = WRITE_THROTTLE_MS) { + let latestMessage; + let timer = null; + let stopped = false; + let startTime; + + function write() { + const elapsed = `Elapsed:\t${formatElapsed(Date.now() - startTime)}`; + const lines = latestMessage ? [latestMessage, elapsed] : [elapsed]; + logUpdate(lines.join('\n')); + } + + function start() { + if (timer) return; + + startTime = Date.now(); + + timer = setInterval(() => { + if (stopped) return; + write(); + }, intervalMs); + } + + function stop() { + stopped = true; + if (timer) clearInterval(timer); + write(); + logUpdate.done(); + } + + // The updater function you call from anywhere + function update(message) { + if (!message) { + stop(); + return; + } + latestMessage = message; + start(); + } + + return { + update, + stop + }; +} + +/** + * Converts a number of bytes into a human-readable string with appropriate units. + * + * @param {number} bytes - The number of bytes to format. + * @returns {string} The formatted string representing the size in appropriate units (Bytes, KB, MB, GB, TB, PB). + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; +} + +/** + * Formats a duration given in milliseconds into a human-readable string. + * + * The output is in the format: + * - "Xh Ym Zs" if the duration is at least 1 hour, + * - "Ym Zs" if the duration is at least 1 minute but less than 1 hour, + * - "Zs" if the duration is less than 1 minute. + * + * @param {number} ms - The duration in milliseconds. + * @returns {string} The formatted elapsed time string. + */ +export function formatElapsed(ms) { + const sec = Math.floor(ms / 1000); + const min = Math.floor(sec / 60); + const hr = Math.floor(min / 60); + if (hr > 0) return `${hr}h ${min % 60}m ${sec % 60}s`; + if (min > 0) return `${min}m ${sec % 60}s`; + return `${sec}s`; +} diff --git a/cliv2-documentation.md b/cliv2-documentation.md new file mode 100644 index 0000000..0c79a40 --- /dev/null +++ b/cliv2-documentation.md @@ -0,0 +1,962 @@ +--- +title: DynamicWeb CLI +_description: Automate and manage DynamicWeb 10 solutions from the command line. +uid: cli +--- + +# DynamicWeb CLI + +The DynamicWeb CLI is a command-line tool for automating and managing DynamicWeb 10 solutions. It connects to the [Management API](xref:dw10-webapis#management-api) to run queries and commands, move files in and out of a solution, install add-ins, export databases, and pull Swift releases. + +The CLI is designed to be composable. Every API-driven command supports structured JSON output, and authentication can be fully configured through environment variables and flags -- no interactive prompts required. This makes the CLI equally useful for one-off tasks at a developer's terminal and for scripted steps in a CI/CD pipeline. + +If you need to do something once, interactively, the DynamicWeb backend UI is usually faster. If you need to do it repeatedly, across environments, or as part of a build -- that is what the CLI is for. + +## What's new in v2 + +Version 2 is an automation-first overhaul. The headline changes: + +- **OAuth client credentials** -- the CLI can now authenticate with an OAuth 2.0 client ID and secret, removing the need for an interactive login in CI/CD pipelines and service-account scenarios. See [Authentication](#authentication) for setup details. +- **Structured JSON output** -- every API-driven command (`env`, `login`, `files`, `folders`, `query`, `command`, `install`) supports `--output json`, returning a consistent envelope with `ok`, `status`, `data`, `errors`, and `meta` fields. Interactive prompts are suppressed in JSON mode so output is safe to pipe. See [Automation and JSON output](#automation-and-json-output). +- **File delete, copy, and move** -- the `files` command can now delete, copy, and move files and directories on the environment in addition to listing, exporting, and importing. See the [files command reference](#files). +- **Folder management** -- the new `folders` command creates, renames, moves, deletes, copies, and exports directories. It always treats the path as a directory, removing the file-extension ambiguity that `dw files` has. See the [folders command reference](#folders). +- **Consistent error model** -- commands that previously printed a message and exited silently now return structured errors (in JSON mode) or throw with a non-zero exit code. Scripts can rely on exit code `1` and the `errors` array for programmatic error handling. + +### Migrating from v1 + +| v1 | v2 | Notes | +|----|-----|-------| +| `--json` (global output flag) | `--output json` | The global `--json` output flag still works but is deprecated. Note: `dw command --json` is a separate, active flag for passing a JSON payload to a command and is **not** deprecated. | +| `--iamstupid` | `--dangerouslyIncludeLogsAndCache` | `--iamstupid` still works but is deprecated | +| `--host` (required `--apiKey`) | `--host` (works with `--apiKey` or OAuth) | OAuth credentials are now accepted alongside `--host` | +| Errors printed to stdout | Errors in `errors` array (JSON) or thrown (non-JSON) | Scripts should check exit codes and/or `ok` field | + +No changes are required for existing scripts that do not use `--json` or `--iamstupid`. Both deprecated flags continue to work and emit a warning. + +## Installation + +The CLI requires **Node.js 20.12.0 or later**. + +Install from npm: + +```sh +npm install -g @dynamicweb/cli +dw --help +``` + +To install from source (for contributors): + +```sh +git clone https://github.com/dynamicweb/CLI.git +cd CLI +npm install +npm install -g . +``` + +## Authentication + +The CLI supports two authentication modes. Which one you use depends on whether a human is present. + +| Mode | Mechanism | Best for | +|------|-----------|----------| +| **User login** | Interactive prompt, API key stored in `~/.dwc` | Local development, exploration | +| **OAuth client credentials** | Client ID + secret via environment variables | CI/CD, service accounts, headless automation | + +Both modes can be overridden on any command with `--apiKey` for direct, environmentless execution using a [manually generated API key](xref:settings-apikeys). + +### Interactive user login + +The default login flow creates a DynamicWeb API key for a backend user and stores it in `~/.dwc`. + +```sh +dw env dev # create or switch to an environment +dw login # interactive prompt for username and password +dw login DemoUser # switch to a previously saved user +``` + +The resulting `~/.dwc` config looks like this: + +```json +{ + "env": { + "dev": { + "host": "localhost:6001", + "protocol": "https", + "users": { + "DemoUser": { + "apiKey": "." + } + }, + "current": { + "user": "DemoUser", + "authType": "user" + } + } + }, + "current": { + "env": "dev" + } +} +``` + +> [!WARNING] +> The interactive login prompt is not verified to work against all DynamicWeb authentication setups. +> +> If `dw login` does not work in your environment, [generate an API key manually](xref:settings-apikeys) and use `--apiKey ` with `--host` and `--protocol` instead. + +### OAuth client credentials + +For service accounts, automation, and any scenario where no human is available to enter a password, the CLI supports [OAuth 2.0 client credentials](xref:oauth). This section covers how to use OAuth with the CLI -- see the linked article for how to set up an OAuth client in DynamicWeb. + +**Configure a saved environment for OAuth:** + +```sh +export DW_CLIENT_ID=my-client-id +export DW_CLIENT_SECRET=my-client-secret + +dw login --oauth +``` + +This stores the environment variable names (not the secrets themselves) in `~/.dwc`: + +```json +{ + "env": { + "dev": { + "host": "localhost:6001", + "protocol": "https", + "auth": { + "type": "oauth_client_credentials", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET" + }, + "current": { + "authType": "oauth_client_credentials" + } + } + }, + "current": { + "env": "dev" + } +} +``` + +**Run a one-off command without saved config:** + +```sh +dw query HealthCheck \ + --host your-solution.example.com \ + --auth oauth \ + --clientIdEnv DW_CLIENT_ID \ + --clientSecretEnv DW_CLIENT_SECRET +``` + +You can also pass credentials directly with `--clientId` and `--clientSecret`, but environment variable references (`--clientIdEnv` / `--clientSecretEnv`) are preferred because they keep secrets out of shell history and process lists. + +### Authentication precedence + +When multiple auth indicators are present, the CLI resolves them in this order: + +1. `--apiKey` -- used directly, no environment required +2. OAuth -- if `--auth oauth`, `--clientId`/`--clientSecret`, `--clientIdEnv`/`--clientSecretEnv`, or the environment is configured for OAuth +3. Saved user -- from `~/.dwc` +4. Interactive prompt -- if nothing else is configured + +Use `--auth user` to force user authentication even when an environment is configured for OAuth. + +## Automation and JSON output + +Commands that talk to the Management API -- `env`, `login`, `files`, `folders`, `query`, `command`, and `install` -- support `--output json`. The `database`, `swift`, and `config` commands do not support `--output json`. This returns a structured envelope instead of human-readable console output: + +```json +{ + "ok": true, + "command": "query", + "operation": "run", + "status": 0, + "data": [ + { + "name": "DefaultMail.html", + "path": "/Templates/Forms/Mail/DefaultMail.html" + } + ], + "errors": [], + "meta": { + "query": "FileByName" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `ok` | `boolean` | `true` if the operation succeeded | +| `command` | `string` | Which CLI command ran | +| `operation` | `string` | The specific operation within that command | +| `status` | `number` | `0` on success, `1` on failure | +| `data` | `array` | The result payload | +| `errors` | `array` | Error objects with `message` and `details` if the operation failed | +| `meta` | `object` | Command-specific metadata (query name, file counts, etc.) | + +When `--output json` is active, all human-readable output to stdout is suppressed. Only the JSON envelope is written to stdout, which makes it safe to pipe. + +### Parsing output in scripts + +Extract data with `jq` or any JSON parser: + +```sh +# Check if a query succeeded +dw query HealthCheck --output json | jq '.ok' + +# Get the list of configured environments +dw env --list --output json | jq -r '.data[0].environments[]' + +# Count files processed during an import +dw files ./Files templates -i -r --output json | jq '.meta.filesProcessed' +``` + +### Error handling in JSON mode + +When a command fails, `ok` is `false` and the `errors` array contains structured error information: + +```json +{ + "ok": false, + "command": "query", + "operation": "run", + "status": 1, + "data": [], + "errors": [ + { + "message": "Query not found", + "details": null + } + ], + "meta": {} +} +``` + +The CLI exits with code `1` on any error, so you can use standard shell exit-code checks alongside JSON parsing. + +## CI/CD + +The recommended CI/CD setup combines OAuth client credentials, `--output json`, and `--host` overrides. This section gives you the patterns for both ephemeral and persistent runners. + +### Secrets + +Store your OAuth credentials in your pipeline's secret store: + +- `DW_CLIENT_ID` -- the OAuth client ID +- `DW_CLIENT_SECRET` -- the OAuth client secret + +These should never be hardcoded in scripts or committed to source control. + +### Ephemeral runners (no saved config) + +On runners that start fresh each time (most cloud CI), pass everything inline. No `~/.dwc` file needed: + +```sh +#!/bin/sh +# Deploy templates and verify with a health check. +# DW_CLIENT_ID and DW_CLIENT_SECRET are set by the pipeline secret store. + +TARGET_HOST="your-solution.example.com" +AUTH_FLAGS="--auth oauth --clientIdEnv DW_CLIENT_ID --clientSecretEnv DW_CLIENT_SECRET" + +# Import templates +dw files ./Files/Templates /Templates -i -r \ + --host "$TARGET_HOST" $AUTH_FLAGS \ + --output json + +# Verify the environment is healthy +RESULT=$(dw query HealthCheck \ + --host "$TARGET_HOST" $AUTH_FLAGS \ + --output json) + +echo "$RESULT" | jq '.ok' +``` + +> [!TIP] +> In GitHub Actions, set `DW_CLIENT_ID` and `DW_CLIENT_SECRET` as repository secrets and reference them with `${{ secrets.DW_CLIENT_ID }}` in the `env` block of your step. In Azure Pipelines, add them as secret variables and they will be available as environment variables. + +### Persistent runners (saved config) + +On long-lived runners, configure the environment once: + +```sh +# One-time setup +dw env production +dw config --env.production.host your-solution.example.com +dw config --env.production.protocol https +export DW_CLIENT_ID=my-client-id +export DW_CLIENT_SECRET=my-client-secret +dw login --oauth +``` + +Then subsequent pipeline steps only need: + +```sh +dw env production +dw query HealthCheck --output json +``` + +### Installing add-ins in pipelines + +Use `dw install --queue` when deploying add-ins in automated pipelines. This defers activation until all files are in place, which avoids partial-load issues when multiple add-ins depend on each other: + +```sh +dw install ./bin/Release/net10.0/MyAddin.dll \ + --queue \ + --host "$TARGET_HOST" $AUTH_FLAGS +``` + +See the [install command reference](#install) for the full explanation of immediate vs. queued installation. + +## Command reference + +### Global options + +These options are available on all API-driven commands: + +| Option | Description | +|--------|-------------| +| `-v`, `--verbose` | Enable verbose logging | +| `--host ` | Use a specific host instead of the saved environment | +| `--protocol ` | Protocol for `--host` (defaults to `https`) | +| `--apiKey ` | Use an API key directly, no saved environment needed | +| `--auth ` | Override authentication mode: `user` or `oauth` | +| `--clientId ` | OAuth client ID (direct value) | +| `--clientSecret ` | OAuth client secret (direct value) | +| `--clientIdEnv ` | Environment variable containing the OAuth client ID | +| `--clientSecretEnv ` | Environment variable containing the OAuth client secret | +| `--output json` | Return structured JSON instead of human-readable output | + +### env + +Create, select, or inspect saved environments. + +```sh +dw env dev # switch to (or create) the "dev" environment +dw env # interactive setup for a new environment +dw env --list # list all configured environments +dw env production --users # list saved users for "production" +``` + +**JSON output:** + +```sh +dw env --list --output json +``` + +```json +{ + "ok": true, + "command": "env", + "operation": "list", + "status": 0, + "data": [ + { + "environments": ["dev", "staging", "production"] + } + ], + "errors": [], + "meta": {} +} +``` + +### login + +Log in interactively, configure OAuth, or switch between saved users. + +```sh +dw login # interactive username/password prompt +dw login DemoUser # switch to a saved user +dw login --oauth # configure OAuth for the current environment +``` + +The interactive login requires a DynamicWeb user with backend access and administrator privileges. The CLI creates an API key named "DW CLI" and stores it in `~/.dwc`. + +**JSON output:** + +```sh +dw login --oauth --output json +``` + +```json +{ + "ok": true, + "command": "login", + "operation": "oauth-login", + "status": 0, + "data": [ + { + "environment": "dev", + "authType": "oauth_client_credentials", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET", + "expires": "2026-04-13T14:22:31Z" + } + ], + "errors": [], + "meta": {} +} +``` + +### files + +List, export, and import files from the DynamicWeb file archive. + +```sh +dw files [dirPath] [outPath] [options] +``` + +**Key options:** + +| Option | Description | +|--------|-------------| +| `-l`, `--list` | List directories (add `-f` to include files) | +| `-f`, `--includeFiles` | Include files in listings | +| `-e`, `--export` | Export from the environment to disk | +| `-i`, `--import` | Import from disk to the environment | +| `-d`, `--delete` | Delete a file or directory on the environment | +| `--empty` | Used with `--delete`, empties a directory instead of removing it | +| `--copy ` | Copy a file or directory to the given destination path on the environment | +| `--move ` | Move a file or directory to the given destination path on the environment | +| `-r`, `--recursive` | Recurse through subdirectories | +| `-o`, `--overwrite` | Allow overwriting existing files on import or move | +| `--createEmpty` | Create files even if the source file is empty | +| `--raw` | Keep exported archives zipped instead of extracting | +| `-af`, `--asFile` | Force the source path to be treated as a file | +| `-ad`, `--asDirectory` | Force the source path to be treated as a directory | +| `--dangerouslyIncludeLogsAndCache` | Include log and cache folders in export | + +**Examples:** + +```sh +# List the system folder structure recursively +dw files system -lr + +# Export templates recursively, including files +dw files templates ./templates -fre + +# Export a single file +dw files templates/Translations.xml ./templates -e + +# Import files from disk, recursively with overwrite +dw files ./Files templates -iro + +# Delete a file +dw files /Templates/Designs/old-bundle.js --delete + +# Delete a directory +dw files /Templates/Designs/OldDesign --delete + +# Empty a directory (remove contents, keep the directory) +dw files /Templates/Designs/MyDesign --delete --empty + +# Copy a directory within the environment +dw files /Templates/Designs/MyDesign --copy /Templates/Designs/MyDesign-backup + +# Copy a file to another directory +dw files /Templates/config.json --copy /Templates/Backups + +# Move a directory +dw files /Templates/Designs/OldName --move /Templates/Designs/Archive + +# Move a file with overwrite +dw files /Templates/config.json --move /Templates/Backups --overwrite +``` + +#### Deleting files and directories + +The `--delete` flag removes files and directories from the environment. The CLI uses the same path detection as other operations -- paths with a file extension are treated as files, paths without are treated as directories. Use `--asFile` or `--asDirectory` to override when needed. + +When used interactively, the CLI prompts for confirmation before deleting. In JSON output mode (`--output json`), the confirmation is skipped to support scripted and CI/CD usage. + +The `--empty` flag can be combined with `--delete` to remove the contents of a directory without removing the directory itself. This is useful for cleaning a deployment target before importing fresh files: + +```sh +# Clean and redeploy +dw files /Templates/Designs/MyDesign --delete --empty \ + --host "$TARGET_HOST" $AUTH_FLAGS --output json + +dw files ./dist /Templates/Designs/MyDesign -iro \ + --host "$TARGET_HOST" $AUTH_FLAGS --output json +``` + +#### Copying and moving files and directories + +The `--copy` and `--move` flags operate on files and directories within the environment. Both accept a destination path as their value. These work with both files and directories -- the server handles detection. + +The `--overwrite` flag can be combined with `--move` to overwrite existing files at the destination. + +```sh +# Back up a design before deploying a new version +dw files /Templates/Designs/MyDesign --copy /Templates/Designs/MyDesign-backup \ + --host "$TARGET_HOST" $AUTH_FLAGS --output json + +# Reorganize files on the server +dw files /Templates/OldLocation/config.json --move /Templates/NewLocation --overwrite \ + --host "$TARGET_HOST" $AUTH_FLAGS --output json +``` + +#### Source type detection + +The CLI infers whether a path is a file or directory based on whether it contains a file extension. This is usually correct, but some paths are ambiguous: + +- A directory named `templates.v1` looks like a file +- A file without an extension looks like a directory + +Use `--asFile` or `--asDirectory` to override the detection: + +```sh +dw files templates/templates.v1 ./templates -e -ad # it's a directory, not a file +dw files templates/Translations.xml ./templates -e -af # force file mode +``` + +> [!NOTE] +> `--asFile` and `--asDirectory` cannot be used together. + +#### Deploying files between environments + +A common workflow is exporting files from one environment and importing them to another: + +```sh +# Export from development +dw env development +dw files templates ./templates -fre + +# Import to staging +dw env staging +dw files ./templates /templates -iro +``` + +This pattern works for any part of the file tree -- templates, designs, integration files, or the entire file archive. + +**JSON output:** + +```sh +dw files ./Files templates -i -r --output json +``` + +```json +{ + "ok": true, + "command": "files", + "operation": "import", + "status": 0, + "data": [ + { + "type": "upload", + "destinationPath": "templates", + "files": [ + "/workspace/Files/Templates/DefaultMail.html" + ], + "response": { + "message": "Upload completed" + } + } + ], + "errors": [], + "meta": { + "filesProcessed": 1, + "chunks": 1 + } +} +``` + +```sh +dw files /Templates/Designs/OldDesign --delete --output json +``` + +```json +{ + "ok": true, + "command": "files", + "operation": "delete", + "status": 0, + "data": [ + { + "type": "delete", + "path": "/Templates/Designs/OldDesign", + "mode": "directory" + } + ], + "errors": [], + "meta": {} +} +``` + +```sh +dw files /Templates/Designs/MyDesign --copy /Templates/Designs/MyDesign-backup --output json +``` + +```json +{ + "ok": true, + "command": "files", + "operation": "copy", + "status": 0, + "data": [ + { + "type": "copy", + "sourcePath": "/Templates/Designs/MyDesign", + "destination": "/Templates/Designs/MyDesign-backup" + } + ], + "errors": [], + "meta": {} +} +``` + +```sh +dw files /Templates/config.json --move /Templates/Backups --output json +``` + +```json +{ + "ok": true, + "command": "files", + "operation": "move", + "status": 0, + "data": [ + { + "type": "move", + "sourcePath": "/Templates/config.json", + "destination": "/Templates/Backups", + "overwrite": false + } + ], + "errors": [], + "meta": {} +} +``` + +### folders + +Create, rename, move, delete, copy, and export directories in the DynamicWeb file archive. + +```sh +dw folders [options] +``` + +Unlike `dw files`, which infers whether a path refers to a file or a directory based on whether it has a file extension, `dw folders` always treats the path as a directory. This avoids ambiguity for paths like `templates.v1` or `my.theme`. + +**Key options:** + +| Option | Description | +|--------|-------------| +| `-c`, `--create` | Create the directory at `` | +| `--rename ` | Rename the directory to a new name, keeping it in the same parent | +| `-m`, `--move ` | Move the directory to the given destination path | +| `-d`, `--delete` | Delete the directory | +| `--empty` | Used with `--delete`, empties the directory instead of removing it | +| `--copy ` | Copy the directory to the given destination path | +| `-e`, `--export` | Export the directory to disk | +| `-o`, `--outPath` | Local destination for `--export` (defaults to `.`) | +| `--raw` | Keep exported archives zipped instead of extracting | + +**Examples:** + +```sh +# Create a new directory +dw folders /Files/NewFolder --create + +# Rename a directory +dw folders /Files/OldName --rename NewName + +# Move a directory into another location +dw folders /Files/MyFolder --move /Files/Archive + +# Delete a directory +dw folders /Files/MyFolder --delete + +# Empty a directory (remove contents, keep the directory) +dw folders /Files/MyFolder --delete --empty + +# Copy a directory +dw folders /Files/MyFolder --copy /Files/MyFolder-backup + +# Export a directory to disk +dw folders /Files/MyFolder --export --outPath ./local +``` + +**Relationship to `dw files`:** `--delete`, `--copy`, and `--export` are routed through the same implementation as `dw files` and produce identical JSON output shapes. `--create`, `--rename`, and `--move` are directory-only operations with no `dw files` equivalent. + +**JSON output:** + +```sh +dw folders /Files/NewFolder --create --output json +``` + +```json +{ + "ok": true, + "command": "folders", + "operation": "create", + "status": 0, + "data": [ + { + "type": "create", + "path": "/Files/NewFolder" + } + ], + "errors": [], + "meta": {} +} +``` + +```sh +dw folders /Files/OldName --rename NewName --output json +``` + +```json +{ + "ok": true, + "command": "folders", + "operation": "rename", + "status": 0, + "data": [ + { + "type": "rename", + "path": "/Files/OldName", + "newName": "NewName" + } + ], + "errors": [], + "meta": {} +} +``` + +```sh +dw folders /Files/MyFolder --move /Files/Archive --output json +``` + +```json +{ + "ok": true, + "command": "folders", + "operation": "move", + "status": 0, + "data": [ + { + "type": "move", + "sourcePath": "/Files/MyFolder", + "destinationPath": "/Files/Archive" + } + ], + "errors": [], + "meta": {} +} +``` + +### query + +Run Management API queries, inspect available parameters, or prompt for them interactively. + +```sh +dw query [-- ...] [options] +``` + +**Key options:** + +| Option | Description | +|--------|-------------| +| `-l`, `--list` | List the query's properties and their types | +| `-i`, `--interactive` | Prompt for each parameter interactively | +| `-- ` | Pass query parameters directly | + +**Examples:** + +```sh +# List properties for a query +dw query FileByName -l + +# Run a query with parameters +dw query FileByName --name DefaultMail.html --directorypath /Templates/Forms/Mail + +# Run interactively (prompts for each parameter) +dw query FileByName --interactive +``` + +**JSON output:** + +```sh +dw query FileByName --name DefaultMail.html --output json +``` + +```json +{ + "ok": true, + "command": "query", + "operation": "run", + "status": 0, + "data": [ + { + "name": "DefaultMail.html", + "path": "/Templates/Forms/Mail/DefaultMail.html" + } + ], + "errors": [], + "meta": { + "query": "FileByName" + } +} +``` + +### command + +Run Management API commands with a JSON payload. + +```sh +dw command --json '' [options] +dw command --json ./payload.json [options] +``` + +The `--json` flag accepts either an inline JSON string or a path to a `.json` file. + +> [!NOTE] +> The `--json` flag here is specific to the `command` subcommand and passes a payload to the Management API. It is **not** the same as the deprecated global `--json` output flag (replaced by `--output json`). + +**Examples:** + +```sh +# Copy a page using an inline JSON payload +dw command PageCopy --json '{ "model": { "SourcePageId": 1189, "DestinationParentPageId": 1129 } }' + +# Move a page using a JSON file +dw command PageMove --json ./PageMove.json + +# Delete a page with JSON output +dw command PageDelete --json '{ "id": "1383" }' --output json +``` + +**JSON output:** + +```json +{ + "ok": true, + "command": "command", + "operation": "run", + "status": 0, + "data": [ + { + "success": true, + "message": "Command executed" + } + ], + "errors": [], + "meta": { + "commandName": "PageDelete" + } +} +``` + +> [!NOTE] +> `dw command --list` is reserved for command metadata but is not fully implemented yet. + +### install + +Upload and install a `.dll` or `.nupkg` add-in into the current environment. + +```sh +dw install [--queue] [--output json] +``` + +**Immediate installation (default):** + +```sh +dw install ./bin/Release/net10.0/CustomProject.dll +``` + +The add-in is uploaded, installed, and activated immediately. This is the right choice for local development and iterative testing. + +**Queued installation:** + +```sh +dw install ./bin/Release/net10.0/CustomProject.dll --queue +``` + +The add-in is uploaded and installed but **not activated** until the next application recycle. Use `--queue` when: + +- Installing multiple add-ins in sequence +- Deploying add-ins that depend on shared libraries or other add-ins +- Running in a CI/CD pipeline where you want all changes to take effect together +- Preparing an environment before a planned restart + +Queued installation ensures all dependencies are in place before any add-in is activated, which avoids partial-load failures. + +> [!NOTE] +> Some add-in types require an application restart regardless of installation mode. In hosted or cloud environments, queued installation is the preferred approach -- see [DynamicWeb Cloud](xref:hosting-dynamicweb-cloud) for guidance on restarts and deployment workflows. + +### database + +Export the current environment's database to a `.bacpac` file. + +> [!NOTE] +> `database` does not support `--output json`. + +```sh +dw database ./backups --export +``` + +The database user needs `db_backupoperator` permissions. To grant them: + +```sql +USE [yourDwDatabaseName] +GO +ALTER ROLE [db_backupoperator] ADD MEMBER [yourDwDbUserName] +GO +``` + +### swift + +Download a Swift release from GitHub. + +> [!NOTE] +> `swift` does not support `--output json`. + +```sh +dw swift [outPath] [options] +``` + +| Option | Description | +|--------|-------------| +| `-l`, `--list` | List all available release versions | +| `-t`, `--tag ` | Download a specific version tag | +| `-n`, `--nightly` | Download the latest commit (HEAD) instead of the latest release | +| `--force` | Overwrite the output directory if it is not empty | + +**Examples:** + +```sh +dw swift -l # list available versions +dw swift . --tag v2.3.0 --force # download a specific version example +dw swift . --nightly --force # download the latest nightly build +``` + +### config + +Write values directly into `~/.dwc` using dot-notation paths. + +> [!NOTE] +> `config` does not support `--output json`. + +```sh +dw config --env.dev.host localhost:6001 +dw config --env.production.protocol https +``` + +This is useful for scripting config updates without editing the JSON file manually. + +## Troubleshooting + +### Git Bash path conversion + +Git Bash on Windows automatically converts paths that look like Unix paths, which can interfere with file operations. If you see unexpected path-conversion behavior, disable it for the session: + +```sh +export MSYS_NO_PATHCONV=1 +dw files -iro ./ ./TestFolder --host --apiKey +``` + +Alternatively, prefix paths with `./` or use PowerShell or CMD instead of Git Bash. diff --git a/package-lock.json b/package-lock.json index c62a2cd..df3ef3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,1533 +1,1389 @@ { "name": "@dynamicweb/cli", - "version": "1.0.0", - "lockfileVersion": 2, + "version": "2.0.0-preview.2", + "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dynamicweb/cli", - "version": "1.0.0", - "license": "ISC", + "version": "2.0.0-preview.2", + "license": "MIT", "dependencies": { - "child_process": "^1.0.2", + "@inquirer/prompts": "^8.4.1", "degit": "^2.8.4", - "fetch": "^1.1.0", + "extract-zip": "^2.0.1", "form-data": "^4.0.0", - "https": "^1.0.0", + "log-update": "^6.1.0", "node-fetch": "^3.2.10", - "path": "^0.12.7", - "yargs": "^17.5.1", - "yargs-interactive": "^3.0.1" + "yargs": "^17.5.1" }, "bin": { "dw": "bin/index.js" }, - "devDependencies": {} - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dependencies": { - "type-fest": "^0.21.3" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20.12.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@inquirer/checkbox": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.4.tgz", + "integrity": "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==", + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=8" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/biskviit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/biskviit/-/biskviit-1.0.1.tgz", - "integrity": "sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w==", + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "license": "MIT", "dependencies": { - "psl": "^1.1.7" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=1.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=6" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@inquirer/editor": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.1.tgz", + "integrity": "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@inquirer/core": "^11.1.9", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=10" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" - }, - "node_modules/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==" - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@inquirer/expand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.13.tgz", + "integrity": "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==", + "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=8" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/cli-width": { + "node_modules/@inquirer/external-editor": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, "engines": { - "node": ">= 10" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@inquirer/input": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.12.tgz", + "integrity": "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==", + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=7.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/@inquirer/number": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.12.tgz", + "integrity": "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==", + "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">= 0.8" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", - "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==", + "node_modules/@inquirer/password": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.12.tgz", + "integrity": "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, "engines": { - "node": ">= 12" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "node_modules/@inquirer/prompts": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, "engines": { - "node": ">=0.10.0" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/degit": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/degit/-/degit-2.8.4.tgz", - "integrity": "sha512-vqYuzmSA5I50J882jd+AbAhQtgK6bdKUJIex1JNfEUPENCgYsxugzKVZlFyMwV4i06MmnV47/Iqi5Io86zf3Ng==", - "bin": { - "degit": "degit" + "node_modules/@inquirer/rawlist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.8.tgz", + "integrity": "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=8.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/@inquirer/search": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.8.tgz", + "integrity": "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, "engines": { - "node": ">=0.4.0" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/encoding": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", - "integrity": "sha512-bl1LAgiQc4ZWr++pNYUdRe/alecaHFeHxIJ/pNciqGdKXghaTCOwKkbKp6ye7pKZGu/GcaSXFk8PBVhgs+dJdA==", + "node_modules/@inquirer/select": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.4.tgz", + "integrity": "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==", + "license": "MIT", "dependencies": { - "iconv-lite": "~0.4.13" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, "engines": { - "node": ">=6" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "license": "MIT", "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-5O8TwrGzoNblBG/jtK4NFuZwNCkZX6s5GfRNOaGtm+QGJEuNakSC/i2RW0R93KX6E0jVjNXm6O3CRN4Ql3K+yA==", + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "optional": true, "dependencies": { - "biskviit": "1.0.1", - "encoding": "0.1.12" + "undici-types": "~7.19.0" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" + "@types/node": "*" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", "dependencies": { - "escape-string-regexp": "^1.0.5" + "environment": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dependencies": { - "locate-path": "^3.0.0" - }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "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==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": "*" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", "dependencies": { - "fetch-blob": "^3.1.2" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=12.20.0" + "node": ">= 0.4" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", "engines": { - "node": ">=8" + "node": ">= 12" } }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + "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/inquirer": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.19", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.6.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-fullwidth-code-point": { + "node_modules/cliui/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/cliui/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "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": ">= 0.6" + "node": ">=8" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=6" + "node": ">=7.0.0" } }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, "engines": { - "node": ">=10.5.0" + "node": ">= 0.8" } }, - "node_modules/node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": ">= 12" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=6" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "node_modules/degit": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/degit/-/degit-2.8.4.tgz", + "integrity": "sha512-vqYuzmSA5I50J882jd+AbAhQtgK6bdKUJIex1JNfEUPENCgYsxugzKVZlFyMwV4i06MmnV47/Iqi5Io86zf3Ng==", + "license": "MIT", + "bin": { + "degit": "degit" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8.0.0" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dependencies": { - "p-try": "^2.0.0" - }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.4.0" } }, - "node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", "dependencies": { - "p-limit": "^2.0.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/path": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", - "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", "dependencies": { - "process": "^0.11.1", - "util": "^0.10.3" + "once": "^1.4.0" } }, - "node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { - "node": ">= 0.6.0" + "node": ">= 0.4" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": ">=6" } }, - "node_modules/rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", "dependencies": { - "tslib": "^1.9.0" + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" }, "engines": { - "npm": ">=2.0.0" + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } }, - "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==" + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" + "pend": "~1.2.0" } }, - "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==", + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": ">=8" + "node": "^12.20 || >= 14.13" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", "dependencies": { - "os-tmpdir": "~1.0.2" + "fetch-blob": "^3.1.2" }, "engines": { - "node": ">=0.6.0" + "node": ">=12.20.0" } }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "engines": { - "node": ">=10" - }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "dependencies": { - "inherits": "2.0.3" + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">= 0.4" } }, - "node_modules/yargs": { - "version": "17.5.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz", - "integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==", + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" + "pump": "^3.0.0" }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs-interactive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/yargs-interactive/-/yargs-interactive-3.0.1.tgz", - "integrity": "sha512-Jnp88uiuz+ZRpM10Lwvs0nRetWPog+6lcgQrhwKsyEanAe3wgTlaPPzcYlZWp53aOMTzOcR5wEpEsFOMOPmLlw==", - "dependencies": { - "inquirer": "^7.0.0", - "yargs": "^14.0.0" - }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { - "node": ">=8", - "npm": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/yargs-interactive/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/yargs-interactive/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/yargs-interactive/node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/yargs-interactive/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/yargs-interactive/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/yargs-interactive/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" - }, - "node_modules/yargs-interactive/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=4" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/yargs-interactive/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs-interactive/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "license": "MIT", "dependencies": { - "ansi-regex": "^4.1.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs-interactive/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/yargs-interactive/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" - }, - "node_modules/yargs-interactive/node_modules/yargs": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", - "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", - "dependencies": { - "cliui": "^5.0.0", - "decamelize": "^1.2.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^15.0.1" - } - }, - "node_modules/yargs-interactive/node_modules/yargs-parser": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.3.tgz", - "integrity": "sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "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==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, "engines": { - "node": ">=12" - } - } - }, - "dependencies": { - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "requires": { - "type-fest": "^0.21.3" + "node": ">= 0.6" } }, - "ansi-regex": { + "node_modules/mimic-function": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "biskviit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/biskviit/-/biskviit-1.0.1.tgz", - "integrity": "sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w==", - "requires": { - "psl": "^1.1.7" - } - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + "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==", + "license": "MIT" }, - "child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==" - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "cli-width": { + "node_modules/mute-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==" - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "data-uri-to-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", - "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==" - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" - }, - "degit": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/degit/-/degit-2.8.4.tgz", - "integrity": "sha512-vqYuzmSA5I50J882jd+AbAhQtgK6bdKUJIex1JNfEUPENCgYsxugzKVZlFyMwV4i06MmnV47/Iqi5Io86zf3Ng==" - }, - "delayed-stream": { + "node_modules/node-domexception": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "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==" - }, - "encoding": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", - "integrity": "sha512-bl1LAgiQc4ZWr++pNYUdRe/alecaHFeHxIJ/pNciqGdKXghaTCOwKkbKp6ye7pKZGu/GcaSXFk8PBVhgs+dJdA==", - "requires": { - "iconv-lite": "~0.4.13" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" } }, - "fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-5O8TwrGzoNblBG/jtK4NFuZwNCkZX6s5GfRNOaGtm+QGJEuNakSC/i2RW0R93KX6E0jVjNXm6O3CRN4Ql3K+yA==", - "requires": { - "biskviit": "1.0.1", - "encoding": "0.1.12" + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "requires": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" } }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "requires": { - "escape-string-regexp": "^1.0.5" + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "requires": { - "locate-path": "^3.0.0" + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, - "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==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "requires": { - "fetch-blob": "^3.1.2" + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" + "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" }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" - }, - "inquirer": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.19", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.6.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT", + "optional": true }, - "node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" - }, - "node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", - "requires": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" } }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "requires": { - "mimic-fn": "^2.1.0" + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "os-tmpdir": { + "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "requires": { - "p-try": "^2.0.0" + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" } }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "requires": { - "p-limit": "^2.0.0" + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" } }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - }, - "path": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", - "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", - "requires": { - "process": "^0.11.1", - "util": "^0.10.3" + "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==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" - }, - "process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" - }, - "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" - }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "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" } }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==" + "node_modules/yargs/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" }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "requires": { - "tslib": "^1.9.0" + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" - }, - "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==" - }, - "string-width": { + "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { + "license": "MIT", + "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" } }, - "strip-ansi": { + "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==", - "requires": { + "license": "MIT", + "dependencies": { "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" - }, - "util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "requires": { - "inherits": "2.0.3" - } - }, - "web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" - }, - "which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, - "yargs": { - "version": "17.5.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz", - "integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==", - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - } - }, - "yargs-interactive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/yargs-interactive/-/yargs-interactive-3.0.1.tgz", - "integrity": "sha512-Jnp88uiuz+ZRpM10Lwvs0nRetWPog+6lcgQrhwKsyEanAe3wgTlaPPzcYlZWp53aOMTzOcR5wEpEsFOMOPmLlw==", - "requires": { - "inquirer": "^7.0.0", - "yargs": "^14.0.0" - }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", "dependencies": { - "ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==" - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "requires": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - } - }, - "y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" - }, - "yargs": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", - "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", - "requires": { - "cliui": "^5.0.0", - "decamelize": "^1.2.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^15.0.1" - } - }, - "yargs-parser": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.3.tgz", - "integrity": "sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==", - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - } + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" } - }, - "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==" } } } diff --git a/package.json b/package.json index fc4b6b5..2ba2b7d 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,52 @@ { "name": "@dynamicweb/cli", "type": "module", - "version": "1.0.0", + "description": "CLI for interacting with Dynamicweb 10 solutions from the command line.", + "version": "2.0.0-preview.2", "main": "bin/index.js", + "files": [ + "bin/**" + ], + "keywords": [ + "dynamicweb", + "cli", + "dynamicweb10", + "cms", + "devops" + ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test", + "qa:smoke": "node qa/run-smoke.mjs" }, - "author": "", - "license": "ISC", + "author": "Dynamicweb A/S (https://www.dynamicweb.com)", + "repository": { + "type": "git", + "url": "git+https://github.com/dynamicweb/CLI.git" + }, + "homepage": "https://github.com/dynamicweb/CLI#readme", + "contributors": [ + "Dynamicweb A/S" + ], + "license": "MIT", "bin": { "dw": "bin/index.js" }, + "bugs": { + "url": "https://github.com/dynamicweb/CLI/issues" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20.12.0" + }, "dependencies": { - "child_process": "^1.0.2", + "@inquirer/prompts": "^8.4.1", "degit": "^2.8.4", - "fetch": "^1.1.0", + "extract-zip": "^2.0.1", "form-data": "^4.0.0", - "https": "^1.0.0", + "log-update": "^6.1.0", "node-fetch": "^3.2.10", - "path": "^0.12.7", - "yargs": "^17.5.1", - "yargs-interactive": "^3.0.1" - }, - "description": "" + "yargs": "^17.5.1" + } } diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..1fa9b71 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,153 @@ +# QA Smoke Harness + +This folder contains a smoke-test harness for running the CLI against a real DynamicWeb solution. + +It is designed to cover both: + +- developer-style usage with a saved environment and `dw login --oauth` +- CI/CD-style usage with `--host --auth oauth` + +The harness currently excludes `database` and `swift`. + +## What It Covers + +- config seeding in an isolated home directory +- `dw env --list` +- `dw env ` +- `dw login --oauth --output json` +- base `dw` output after saved OAuth login +- `dw files` list, import, export, copy, move, and delete +- configured `dw query` smoke checks +- configured `dw command` smoke checks +- optional `dw install` if you provide a real `.dll` or `.nupkg` + +## Safety Model + +- The runner sets its own temporary `HOME`, so it does not touch the engineer's real `~/.dwc`. +- File tests use a unique remote folder under `remoteRoot//...`. +- The runner attempts remote cleanup automatically unless you pass `--keep-remote`. + +## Quick Start + +Set the required credentials: + +**bash / WSL:** +```sh +export DW_BASE_URL=https://your-solution.example.com +export DW_CLIENT_ID=your-client-id +export DW_CLIENT_SECRET=your-client-secret +``` + +**PowerShell:** +```powershell +$env:DW_BASE_URL = "https://your-solution.example.com" +$env:DW_CLIENT_ID = "your-client-id" +$env:DW_CLIENT_SECRET = "your-client-secret" +``` + +Run with defaults: + +```sh +npm run qa:smoke +``` + +Run with a custom profile: + +```sh +npm run qa:smoke -- --profile qa/profile.json +``` + +Run only one mode: + +```sh +npm run qa:smoke -- --mode saved-env +npm run qa:smoke -- --mode ephemeral +``` + +Artifacts are written to `qa/artifacts//`. + +## Configuration + +The runner works without a profile if these environment variables are present: + +- `DW_BASE_URL` +- `DW_CLIENT_ID` +- `DW_CLIENT_SECRET` + +Defaults: + +- `environmentName`: `qa-smoke` +- `clientIdEnv`: `DW_CLIENT_ID` +- `clientSecretEnv`: `DW_CLIENT_SECRET` +- `remoteRoot`: `QA/CLI` +- `commandTimeoutMs`: `120000` +- `queries`: `[]` +- `commands`: `[]` +- `install.enabled`: `false` + +If `qa/profile.json` exists, it is loaded automatically. You can also pass `--profile `. + +To get started, copy the template and customize it: + +```sh +cp qa/profile.example.json qa/profile.json +``` + +See [profile.example.json](profile.example.json) for the full list of supported fields. + +## Profile Example + +```json +{ + "environmentName": "qa-smoke", + "baseUrl": "https://your-solution.example.com", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET", + "remoteRoot": "QA/CLI", + "commandTimeoutMs": 120000, + "queries": [ + { + "name": "YourReadOnlyQuery", + "params": { + "id": "123" + } + } + ], + "commands": [ + { + "name": "YourSafeCommand", + "body": { + "model": { + "id": "123" + } + } + } + ], + "install": { + "enabled": false, + "filePath": "qa/fixtures/addins/YourAddin.nupkg", + "queue": true + } +} +``` + +## CI Example + +The same command works in CI as long as the secret store exposes the required variables: + +```sh +npm ci +npm run qa:smoke -- --mode all --profile qa/profile.json +``` + +To shorten or extend the per-command timeout in CI: + +```sh +npm run qa:smoke -- --timeoutMs 180000 +``` + +## Notes + +- Prefer read-only queries and commands unless you have a dedicated QA tenant and fixture data. +- `dw command --list` is not included because the command is not implemented in the CLI yet. +- `dw install` is optional because it needs a real package and changes the target solution. diff --git a/qa/fixtures/files/source/copied/keep.txt b/qa/fixtures/files/source/copied/keep.txt new file mode 100644 index 0000000..6fc4417 --- /dev/null +++ b/qa/fixtures/files/source/copied/keep.txt @@ -0,0 +1 @@ +This file keeps the copied directory in place for copy tests. diff --git a/qa/fixtures/files/source/moved/keep.txt b/qa/fixtures/files/source/moved/keep.txt new file mode 100644 index 0000000..2c51f74 --- /dev/null +++ b/qa/fixtures/files/source/moved/keep.txt @@ -0,0 +1 @@ +This file keeps the moved directory in place for move tests. diff --git a/qa/fixtures/files/source/nested/nested-upload.txt b/qa/fixtures/files/source/nested/nested-upload.txt new file mode 100644 index 0000000..87d58ac --- /dev/null +++ b/qa/fixtures/files/source/nested/nested-upload.txt @@ -0,0 +1 @@ +Nested fixture for the DynamicWeb CLI QA smoke run. diff --git a/qa/fixtures/files/source/smoke-upload.txt b/qa/fixtures/files/source/smoke-upload.txt new file mode 100644 index 0000000..8dfca8d --- /dev/null +++ b/qa/fixtures/files/source/smoke-upload.txt @@ -0,0 +1 @@ +DynamicWeb CLI QA smoke file. diff --git a/qa/profile.example.json b/qa/profile.example.json new file mode 100644 index 0000000..f3d4633 --- /dev/null +++ b/qa/profile.example.json @@ -0,0 +1,15 @@ +{ + "environmentName": "qa-smoke", + "baseUrl": "https://your-solution.example.com", + "clientIdEnv": "DW_CLIENT_ID", + "clientSecretEnv": "DW_CLIENT_SECRET", + "remoteRoot": "QA/CLI", + "commandTimeoutMs": 120000, + "queries": [], + "commands": [], + "install": { + "enabled": false, + "filePath": "qa/fixtures/addins/YourAddin.nupkg", + "queue": true + } +} diff --git a/qa/run-smoke.mjs b/qa/run-smoke.mjs new file mode 100644 index 0000000..15115cb --- /dev/null +++ b/qa/run-smoke.mjs @@ -0,0 +1,1030 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const defaultProfilePath = path.join(__dirname, 'profile.json'); +const exampleProfilePath = path.join(__dirname, 'profile.example.json'); + +const defaultConfig = { + environmentName: 'qa-smoke', + clientIdEnv: 'DW_CLIENT_ID', + clientSecretEnv: 'DW_CLIENT_SECRET', + remoteRoot: 'QA/CLI', + commandTimeoutMs: 120000, + queries: [], + commands: [], + install: { + enabled: false, + filePath: '', + queue: true + } +}; + +const args = parseArgs(process.argv.slice(2)); + +if (args.help) { + printHelp(); + process.exit(0); +} + +const runId = createRunId(); +const artifactsDir = path.join(__dirname, 'artifacts', runId); +const logsDir = path.join(artifactsDir, 'logs'); +const workspaceDir = path.join(artifactsDir, 'workspace'); +const homeDir = path.join(artifactsDir, 'home'); +const reportPath = path.join(artifactsDir, 'report.json'); + +fs.mkdirSync(logsDir, { recursive: true }); +fs.mkdirSync(workspaceDir, { recursive: true }); +fs.mkdirSync(homeDir, { recursive: true }); + +const profile = loadProfile(args.profile); +const config = buildConfig(profile, args); +const cleanupTargets = []; + +const report = { + runId, + startedAt: new Date().toISOString(), + finishedAt: null, + status: 'running', + mode: config.mode, + artifactsDir, + config: { + baseUrl: config.baseUrl, + protocol: config.protocol, + host: config.host, + environmentName: config.environmentName, + clientIdEnv: config.clientIdEnv, + clientSecretEnv: config.clientSecretEnv, + remoteRoot: config.remoteRoot, + commandTimeoutMs: config.commandTimeoutMs, + profilePath: config.profilePath + }, + steps: [] +}; + +const sharedEnv = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + HOMEPATH: homeDir, + [config.clientIdEnv]: process.env[config.clientIdEnv], + [config.clientSecretEnv]: process.env[config.clientSecretEnv] +}; + +main().catch(async (error) => { + report.status = 'failed'; + report.error = { + message: error.message, + stack: error.stack + }; + report.finishedAt = new Date().toISOString(); + flushReport(); + await cleanupRemoteTargets(true); + console.error(`QA smoke run failed: ${error.message}`); + console.error(`Report: ${reportPath}`); + process.exitCode = 1; +}); + +async function main() { + console.log(`QA smoke run ${runId}`); + console.log(`Artifacts: ${artifactsDir}`); + + await runStep('validate configuration', async () => { + assert(process.env[config.clientIdEnv], `Missing ${config.clientIdEnv} in the environment.`); + assert(process.env[config.clientSecretEnv], `Missing ${config.clientSecretEnv} in the environment.`); + + return { + baseUrl: config.baseUrl, + host: config.host, + protocol: config.protocol, + environmentName: config.environmentName, + remoteRoot: config.remoteRoot + }; + }); + + const localSourceDir = await runStep('prepare local fixtures', async () => { + const destination = path.join(workspaceDir, 'local-source'); + const templateDir = path.join(__dirname, 'fixtures', 'files', 'source'); + fs.cpSync(templateDir, destination, { recursive: true }); + fs.appendFileSync( + path.join(destination, 'smoke-upload.txt'), + `\nRunId=${runId}\nGeneratedAt=${new Date().toISOString()}\n` + ); + + return { + localSourceDir: destination, + files: listFilesRecursively(destination).map(file => path.relative(destination, file)) + }; + }); + + if (config.mode === 'all' || config.mode === 'saved-env') { + await runSavedEnvironmentFlow(localSourceDir.localSourceDir); + } else { + skipStep('saved environment flow', 'Skipped because --mode is not saved-env or all.'); + } + + if (config.mode === 'all' || config.mode === 'ephemeral') { + await runEphemeralFlow(localSourceDir.localSourceDir); + } else { + skipStep('ephemeral flow', 'Skipped because --mode is not ephemeral or all.'); + } + + await cleanupRemoteTargets(false); + + report.status = 'passed'; + report.finishedAt = new Date().toISOString(); + flushReport(); + + console.log('QA smoke run passed.'); + console.log(`Report: ${reportPath}`); +} + +async function runSavedEnvironmentFlow(localSourceDir) { + console.log('Running saved-environment flow...'); + + await runStep('saved-env: seed config', async () => { + await runCliText('saved-env-config', [ + 'config', + `--env.${config.environmentName}.host`, config.host, + `--env.${config.environmentName}.protocol`, config.protocol, + '--current.env', config.environmentName + ]); + + const configPath = path.join(homeDir, '.dwc'); + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + assert(persisted?.env?.[config.environmentName]?.host === config.host, 'Host was not persisted to the isolated config.'); + assert(persisted?.env?.[config.environmentName]?.protocol === config.protocol, 'Protocol was not persisted to the isolated config.'); + assert(persisted?.current?.env === config.environmentName, 'Current environment was not persisted to the isolated config.'); + + return { + configPath, + environmentName: config.environmentName + }; + }); + + await runStep('saved-env: list environments', async () => { + const response = await runCliJson('saved-env-env-list', ['env', '--list', '--output', 'json']); + assertEnvelope(response, 'env', 'list'); + + return response.data[0]; + }); + + await runStep('saved-env: select environment', async () => { + const response = await runCliJson('saved-env-env-select', ['env', config.environmentName, '--output', 'json']); + assertEnvelope(response, 'env', 'select'); + + return response.data[0]; + }); + + await runStep('saved-env: oauth login', async () => { + const response = await runCliJson('saved-env-login', [ + 'login', + '--oauth', + '--clientIdEnv', config.clientIdEnv, + '--clientSecretEnv', config.clientSecretEnv, + '--output', 'json' + ]); + + assertEnvelope(response, 'login', 'oauth-login'); + + return response.data[0]; + }); + + await runStep('saved-env: base command', async () => { + const response = await runCliText('saved-env-base-command', []); + + assert(response.stdout.includes(`Environment: ${config.environmentName}`), 'Base command output is missing the configured environment.'); + assert(response.stdout.includes('Authentication: OAuth client credentials'), 'Base command output is missing the OAuth authentication line.'); + assert(response.stdout.includes(`Host: ${config.host}`), 'Base command output is missing the configured host.'); + + return { + stdout: response.stdout.trim() + }; + }); + + await runConfiguredQueryChecks('saved-env', []); + await runConfiguredCommandChecks('saved-env', []); + + const remoteRoot = path.posix.join(config.remoteRoot, runId, 'saved-env'); + cleanupTargets.push({ + name: 'saved-env', + remoteRoot, + authArgs: [] + }); + + await runFileSuite('saved-env', localSourceDir, remoteRoot, []); + await runInstallCheck('saved-env', []); +} + +async function runEphemeralFlow(localSourceDir) { + console.log('Running ephemeral flow...'); + + const authArgs = getEphemeralAuthArgs(); + + await runStep('ephemeral: root files list', async () => { + const response = await runCliJson('ephemeral-files-root-list', ['files', '--list', '--output', 'json', ...authArgs]); + assertEnvelope(response, 'files', 'list'); + + return { + rootName: response.data[0]?.name ?? '/' + }; + }); + + await runConfiguredQueryChecks('ephemeral', authArgs); + await runConfiguredCommandChecks('ephemeral', authArgs); + + const remoteRoot = path.posix.join(config.remoteRoot, runId, 'ephemeral'); + cleanupTargets.push({ + name: 'ephemeral', + remoteRoot, + authArgs + }); + + await runFileSuite('ephemeral', localSourceDir, remoteRoot, authArgs); +} + +async function runConfiguredQueryChecks(modeLabel, authArgs) { + const enabledQueries = (config.queries || []).filter(query => query.enabled !== false); + + if (enabledQueries.length === 0) { + skipStep(`${modeLabel}: query checks`, 'No queries configured.'); + return; + } + + for (const query of enabledQueries) { + if (query.checkList !== false) { + await runStep(`${modeLabel}: query list ${query.name}`, async () => { + const response = await runCliJson(`${modeLabel}-query-list-${query.name}`, [ + 'query', + query.name, + '--list', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'query', 'list'); + + return { + query: query.name, + parameters: response.data[0] + }; + }); + } + + if (query.listOnly) { + skipStep(`${modeLabel}: query run ${query.name}`, 'Skipped because listOnly is true.'); + continue; + } + + await runStep(`${modeLabel}: query run ${query.name}`, async () => { + const response = await runCliJson(`${modeLabel}-query-run-${query.name}`, [ + 'query', + query.name, + ...serializeOptions(query.params), + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'query', 'run'); + + return { + query: query.name, + resultCount: response.data.length + }; + }); + } +} + +async function runConfiguredCommandChecks(modeLabel, authArgs) { + const enabledCommands = (config.commands || []).filter(command => command.enabled !== false); + + if (enabledCommands.length === 0) { + skipStep(`${modeLabel}: command checks`, 'No commands configured.'); + return; + } + + for (const command of enabledCommands) { + await runStep(`${modeLabel}: command run ${command.name}`, async () => { + const serializedParams = serializeOptions(command.params); + const args = [ + 'command', + command.name, + ...serializedParams, + '--output', 'json', + ...authArgs + ]; + + const body = resolveCommandBody(command); + if (body !== null) { + args.splice(2 + serializedParams.length, 0, '--json', body); + } + + const response = await runCliJson(`${modeLabel}-command-run-${command.name}`, args); + assertEnvelope(response, 'command', 'run'); + + return { + command: command.name, + resultCount: response.data.length + }; + }); + } +} + +async function runFileSuite(modeLabel, localSourceDir, remoteRoot, authArgs) { + const exportRoot = path.join(workspaceDir, `${modeLabel}-export`); + const expectedLocalFile = path.join(localSourceDir, 'smoke-upload.txt'); + const expectedExportedFile = path.join(exportRoot, path.posix.basename(remoteRoot), 'smoke-upload.txt'); + + await runStep(`${modeLabel}: files import`, async () => { + const response = await runCliJson(`${modeLabel}-files-import`, [ + 'files', + localSourceDir, + remoteRoot, + '--import', + '--recursive', + '--overwrite', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'import'); + assert((response.meta?.filesProcessed || 0) >= 4, 'Import did not process the expected number of fixture files.'); + + return { + remoteRoot, + filesProcessed: response.meta.filesProcessed, + chunks: response.meta.chunks + }; + }); + + await runStep(`${modeLabel}: files list imported tree`, async () => { + const response = await runCliJson(`${modeLabel}-files-list-imported`, [ + 'files', + remoteRoot, + '--list', + '--recursive', + '--includeFiles', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'list'); + const listing = flattenListing(response.data[0], remoteRoot); + + assert(listing.files.includes(path.posix.join(remoteRoot, 'smoke-upload.txt')), 'Imported root file is missing from the remote listing.'); + assert(listing.files.includes(path.posix.join(remoteRoot, 'nested', 'nested-upload.txt')), 'Imported nested file is missing from the remote listing.'); + + return { + directories: listing.directories, + files: listing.files + }; + }); + + await runStep(`${modeLabel}: files export`, async () => { + fs.mkdirSync(exportRoot, { recursive: true }); + + const response = await runCliJson(`${modeLabel}-files-export`, [ + 'files', + remoteRoot, + exportRoot, + '--export', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'export'); + assert(fs.existsSync(expectedExportedFile), `Expected exported file was not found at ${expectedExportedFile}.`); + + const expectedContent = fs.readFileSync(expectedLocalFile, 'utf8'); + const exportedContent = fs.readFileSync(expectedExportedFile, 'utf8'); + assert(exportedContent === expectedContent, 'Exported file content does not match the uploaded fixture.'); + + return { + exportedFile: expectedExportedFile + }; + }); + + await runStep(`${modeLabel}: files copy`, async () => { + const sourcePath = path.posix.join(remoteRoot, 'smoke-upload.txt'); + const destination = path.posix.join(remoteRoot, 'copied'); + const response = await runCliJson(`${modeLabel}-files-copy`, [ + 'files', + sourcePath, + '--copy', destination, + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'copy'); + + return { + sourcePath, + destination + }; + }); + + await runStep(`${modeLabel}: files verify copied`, async () => { + const response = await runCliJson(`${modeLabel}-files-list-copied`, [ + 'files', + path.posix.join(remoteRoot, 'copied'), + '--list', + '--includeFiles', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'list'); + const listing = flattenListing(response.data[0], path.posix.join(remoteRoot, 'copied')); + assert(listing.files.includes(path.posix.join(remoteRoot, 'copied', 'smoke-upload.txt')), 'Copied file is missing from the destination directory.'); + + return { + files: listing.files + }; + }); + + await runStep(`${modeLabel}: files move`, async () => { + const sourcePath = path.posix.join(remoteRoot, 'copied', 'smoke-upload.txt'); + const destination = path.posix.join(remoteRoot, 'moved'); + const response = await runCliJson(`${modeLabel}-files-move`, [ + 'files', + sourcePath, + '--move', destination, + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'move'); + + return { + sourcePath, + destination + }; + }); + + await runStep(`${modeLabel}: files verify moved`, async () => { + const response = await runCliJson(`${modeLabel}-files-list-moved`, [ + 'files', + path.posix.join(remoteRoot, 'moved'), + '--list', + '--includeFiles', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'list'); + const listing = flattenListing(response.data[0], path.posix.join(remoteRoot, 'moved')); + assert(listing.files.includes(path.posix.join(remoteRoot, 'moved', 'smoke-upload.txt')), 'Moved file is missing from the destination directory.'); + + return { + files: listing.files + }; + }); + + await runStep(`${modeLabel}: files delete file`, async () => { + const target = path.posix.join(remoteRoot, 'moved', 'smoke-upload.txt'); + const response = await runCliJson(`${modeLabel}-files-delete-file`, [ + 'files', + target, + '--delete', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'delete'); + + return { + deleted: target + }; + }); + + await runStep(`${modeLabel}: files verify deleted file`, async () => { + const response = await runCliJson(`${modeLabel}-files-list-after-delete`, [ + 'files', + path.posix.join(remoteRoot, 'moved'), + '--list', + '--includeFiles', + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'files', 'list'); + const listing = flattenListing(response.data[0], path.posix.join(remoteRoot, 'moved')); + assert(!listing.files.includes(path.posix.join(remoteRoot, 'moved', 'smoke-upload.txt')), 'Deleted file is still present in the destination directory.'); + + return { + files: listing.files + }; + }); +} + +async function runInstallCheck(modeLabel, authArgs) { + const installConfig = config.install || {}; + + if (!installConfig.enabled) { + skipStep(`${modeLabel}: install check`, 'Install is disabled in the QA profile.'); + return; + } + + await runStep(`${modeLabel}: install check`, async () => { + const filePath = path.resolve(repoRoot, installConfig.filePath); + assert(fs.existsSync(filePath), `Install fixture was not found: ${filePath}`); + + const response = await runCliJson(`${modeLabel}-install`, [ + 'install', + filePath, + ...(installConfig.queue ? ['--queue'] : []), + '--output', 'json', + ...authArgs + ]); + + assertEnvelope(response, 'install', installConfig.queue ? 'queue' : 'install'); + + return { + filePath + }; + }); +} + +async function cleanupRemoteTargets(alwaysAttempt) { + if (args.keepRemote) { + if (alwaysAttempt) { + console.warn('Remote cleanup skipped because --keep-remote is enabled.'); + } + return; + } + + for (const target of cleanupTargets) { + try { + const response = await runCliJson(`cleanup-${target.name}`, [ + 'files', + target.remoteRoot, + '--delete', + '--output', 'json', + ...target.authArgs + ], { + allowFailure: true + }); + + if (response?.ok) { + console.log(`Cleaned remote folder for ${target.name}: ${target.remoteRoot}`); + } + } catch (error) { + if (alwaysAttempt) { + console.warn(`Best-effort cleanup failed for ${target.remoteRoot}: ${error.message}`); + } else { + throw error; + } + } + } +} + +async function runStep(name, fn) { + const startedAt = new Date(); + const entry = { + name, + status: 'running', + startedAt: startedAt.toISOString() + }; + + report.steps.push(entry); + flushReport(); + + try { + const details = await fn(); + entry.status = 'passed'; + entry.details = details; + entry.finishedAt = new Date().toISOString(); + entry.durationMs = new Date(entry.finishedAt).getTime() - startedAt.getTime(); + flushReport(); + console.log(`PASS ${name}`); + return details; + } catch (error) { + entry.status = 'failed'; + entry.error = { + message: error.message + }; + entry.finishedAt = new Date().toISOString(); + entry.durationMs = new Date(entry.finishedAt).getTime() - startedAt.getTime(); + flushReport(); + console.error(`FAIL ${name}: ${error.message}`); + throw error; + } +} + +function skipStep(name, reason) { + report.steps.push({ + name, + status: 'skipped', + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + durationMs: 0, + details: { + reason + } + }); + flushReport(); + console.log(`SKIP ${name}: ${reason}`); +} + +async function runCliJson(logName, cliArgs, options = {}) { + const result = await runCli(logName, cliArgs, { ...options, expectJson: true }); + + if (!result.parsed && !options.allowFailure) { + throw new Error(`Expected JSON output from ${logName}, but stdout was empty.`); + } + + return result.parsed; +} + +async function runCliText(logName, cliArgs, options = {}) { + return await runCli(logName, cliArgs, { ...options, expectJson: false }); +} + +async function runCli(logName, cliArgs, options = {}) { + const command = [process.execPath, path.join('bin', 'index.js'), ...cliArgs]; + const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs; + const child = spawn(command[0], command.slice(1), { + cwd: repoRoot, + env: sharedEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', chunk => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + + let exited = false; + child.on('exit', () => { exited = true; }); + + const timeoutHandle = setTimeout(() => { + stderr += `\nProcess timed out after ${timeoutMs} ms.`; + child.kill('SIGTERM'); + + setTimeout(() => { + if (!exited) { + child.kill('SIGKILL'); + } + }, 5000).unref(); + }, timeoutMs); + + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + clearTimeout(timeoutHandle); + + let parsed = null; + if (options.expectJson && stdout.trim()) { + try { + parsed = JSON.parse(stdout); + } catch (error) { + throw new Error(`Failed to parse JSON output for ${logName}: ${error.message}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`); + } + } + + const logPath = path.join(logsDir, `${String(report.steps.length).padStart(2, '0')}-${slugify(logName)}.json`); + fs.writeFileSync(logPath, JSON.stringify({ + command, + timeoutMs, + exitCode, + stdout, + stderr, + parsed + }, null, 2)); + + if (exitCode !== 0 && !options.allowFailure) { + const details = parsed?.errors?.map(error => error.message).join('; ') || stderr.trim() || stdout.trim(); + throw new Error(`${logName} exited with code ${exitCode}. ${details}`); + } + + return { + exitCode, + stdout, + stderr, + parsed, + logPath + }; +} + +function buildConfig(profile, cliArgs) { + const merged = { + ...defaultConfig, + ...profile, + install: { + ...defaultConfig.install, + ...(profile.install || {}) + } + }; + + const baseUrl = cliArgs.baseUrl || process.env.DW_BASE_URL || merged.baseUrl; + assert(baseUrl, 'Missing base URL. Set DW_BASE_URL, add it to qa/profile.json, or pass --baseUrl.'); + + const { protocol, host, normalizedBaseUrl } = normalizeBaseUrl(baseUrl); + + return { + ...merged, + protocol, + host, + baseUrl: normalizedBaseUrl, + environmentName: cliArgs.environmentName || merged.environmentName, + clientIdEnv: cliArgs.clientIdEnv || merged.clientIdEnv, + clientSecretEnv: cliArgs.clientSecretEnv || merged.clientSecretEnv, + remoteRoot: normalizeRemoteRoot(cliArgs.remoteRoot || merged.remoteRoot), + commandTimeoutMs: parseTimeout(cliArgs.timeoutMs ?? merged.commandTimeoutMs), + mode: cliArgs.mode, + profilePath: profile.__profilePath || null + }; +} + +function loadProfile(profilePathArg) { + let profilePath = null; + + if (profilePathArg) { + profilePath = path.resolve(repoRoot, profilePathArg); + assert(fs.existsSync(profilePath), `Profile file not found: ${profilePath}`); + } else if (fs.existsSync(defaultProfilePath)) { + profilePath = defaultProfilePath; + } + + if (!profilePath) { + return { __profilePath: null }; + } + + const profile = JSON.parse(fs.readFileSync(profilePath, 'utf8')); + profile.__profilePath = profilePath; + return profile; +} + +function parseArgs(argv) { + const parsed = { + profile: null, + mode: 'all', + baseUrl: null, + remoteRoot: null, + environmentName: null, + clientIdEnv: null, + clientSecretEnv: null, + timeoutMs: null, + keepRemote: false, + help: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + continue; + } + + if (arg === '--keep-remote') { + parsed.keepRemote = true; + continue; + } + + const next = argv[index + 1]; + if (!next) { + throw new Error(`Missing value for ${arg}`); + } + + if (arg === '--profile') { + parsed.profile = next; + index += 1; + continue; + } + + if (arg === '--mode') { + assert(['all', 'saved-env', 'ephemeral'].includes(next), `Invalid mode "${next}". Expected all, saved-env, or ephemeral.`); + parsed.mode = next; + index += 1; + continue; + } + + if (arg === '--baseUrl') { + parsed.baseUrl = next; + index += 1; + continue; + } + + if (arg === '--remoteRoot') { + parsed.remoteRoot = next; + index += 1; + continue; + } + + if (arg === '--environmentName') { + parsed.environmentName = next; + index += 1; + continue; + } + + if (arg === '--clientIdEnv') { + parsed.clientIdEnv = next; + index += 1; + continue; + } + + if (arg === '--clientSecretEnv') { + parsed.clientSecretEnv = next; + index += 1; + continue; + } + + if (arg === '--timeoutMs') { + parsed.timeoutMs = next; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return parsed; +} + +function printHelp() { + console.log(` +Usage: + npm run qa:smoke + npm run qa:smoke -- --profile qa/profile.json + +Options: + --profile Load a QA profile JSON file + --mode + --baseUrl Override the solution base URL + --remoteRoot Override the remote QA root directory + --environmentName Override the saved environment name + --clientIdEnv Override the OAuth client ID env var name + --clientSecretEnv Override the OAuth client secret env var name + --timeoutMs Fail a CLI invocation if it runs longer than this + --keep-remote Leave remote QA folders behind for debugging + --help Show this help + +Defaults: + profile: qa/profile.json if present, otherwise no profile + base URL: DW_BASE_URL + client ID env: DW_CLIENT_ID + client secret env: DW_CLIENT_SECRET + command timeout: 120000 ms +`.trim()); + + if (fs.existsSync(exampleProfilePath)) { + console.log(`\nExample profile: ${exampleProfilePath}`); + } +} + +function getEphemeralAuthArgs() { + return [ + '--host', config.host, + '--protocol', config.protocol, + '--auth', 'oauth', + '--clientIdEnv', config.clientIdEnv, + '--clientSecretEnv', config.clientSecretEnv + ]; +} + +function normalizeBaseUrl(rawValue) { + const baseValue = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`; + const parsed = new URL(baseValue); + + assert(!parsed.username && !parsed.password, 'The base URL must not include embedded credentials.'); + assert(!parsed.search && !parsed.hash, 'The base URL must not include a query string or hash fragment.'); + assert(parsed.pathname === '/' || parsed.pathname === '', 'The base URL must not include a path segment.'); + + return { + protocol: parsed.protocol.replace(':', ''), + host: parsed.host, + normalizedBaseUrl: `${parsed.protocol}//${parsed.host}` + }; +} + +function normalizeRemoteRoot(remoteRoot) { + return remoteRoot.replace(/^\/+/, '').replace(/\/+$/, ''); +} + +function parseTimeout(rawValue) { + const timeoutMs = Number(rawValue); + assert(Number.isFinite(timeoutMs) && timeoutMs > 0, `Invalid timeout "${rawValue}". Expected a positive number of milliseconds.`); + return timeoutMs; +} + +function resolveCommandBody(command) { + if (command.bodyFile) { + const profileDir = config.profilePath ? path.dirname(config.profilePath) : repoRoot; + const bodyFile = path.resolve(profileDir, command.bodyFile); + assert(fs.existsSync(bodyFile), `Command body file not found: ${bodyFile}`); + return fs.readFileSync(bodyFile, 'utf8'); + } + + if (command.body !== undefined) { + return JSON.stringify(command.body); + } + + return null; +} + +function serializeOptions(options = {}) { + const args = []; + + for (const [key, value] of Object.entries(options)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + args.push(`--${key}`, String(item)); + } + continue; + } + + if (typeof value === 'boolean') { + args.push(`--${key}`, String(value)); + continue; + } + + args.push(`--${key}`, String(value)); + } + + return args; +} + +function flattenListing(root, rootPath = '') { + const directories = rootPath ? [rootPath] : []; + const files = []; + + walkListing(root, rootPath, directories, files, true); + + return { directories, files }; +} + +function walkListing(node, currentPath, directories, files, isRoot = false) { + if (!isRoot && currentPath) { + directories.push(currentPath); + } + + for (const file of node?.files?.data || []) { + files.push(path.posix.join(currentPath, file.name)); + } + + for (const directory of node?.directories || []) { + walkListing(directory, path.posix.join(currentPath, directory.name), directories, files); + } +} + +function listFilesRecursively(rootDir) { + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const resolved = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursively(resolved)); + } else { + files.push(resolved); + } + } + + return files.sort(); +} + +function assertEnvelope(response, command, operation) { + assert(response, `No response received for ${command}.`); + assert(response.command === command, `Expected command "${command}" but got "${response.command}".`); + assert(response.operation === operation, `Expected operation "${operation}" but got "${response.operation}".`); + assert(response.ok === true, `${command} returned a non-ok response.`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function flushReport() { + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); +} + +function createRunId() { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `qa-smoke-${timestamp}`; +} + +function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} diff --git a/test/command.test.js b/test/command.test.js new file mode 100644 index 0000000..39ff76b --- /dev/null +++ b/test/command.test.js @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { getQueryParams, parseJsonOrPath } from '../bin/commands/command.js'; + +test('getQueryParams prefixes custom args and excludes framework args', () => { + const params = getQueryParams({ + command: 'DoThing', + host: 'example.com', + protocol: 'https', + verbose: true, + id: 42, + culture: 'en-US' + }); + + assert.deepEqual(params, { + 'Command.id': 42, + 'Command.culture': 'en-US' + }); +}); + +test('parseJsonOrPath returns undefined for empty input', () => { + assert.equal(parseJsonOrPath(), undefined); +}); + +test('parseJsonOrPath parses literal json', () => { + assert.deepEqual(parseJsonOrPath('{"model":{"id":123}}'), { + model: { + id: 123 + } + }); +}); + +test('parseJsonOrPath parses json from a file path', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-command-test-')); + const jsonPath = path.join(tempDir, 'body.json'); + + try { + fs.writeFileSync(jsonPath, '{"model":{"id":456}}'); + + assert.deepEqual(parseJsonOrPath(jsonPath), { + model: { + id: 456 + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('parseJsonOrPath throws SyntaxError for a file containing invalid JSON', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-command-test-')); + const jsonPath = path.join(tempDir, 'bad.json'); + + try { + fs.writeFileSync(jsonPath, '{ not valid json }'); + assert.throws(() => parseJsonOrPath(jsonPath), SyntaxError); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('parseJsonOrPath throws SyntaxError for a non-existent file path', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-command-test-')); + + try { + const missingPath = path.join(tempDir, 'missing.json'); + // existsSync returns false, so the path string is passed to JSON.parse directly + assert.throws(() => parseJsonOrPath(missingPath), SyntaxError); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/test/downloader.test.js b/test/downloader.test.js new file mode 100644 index 0000000..eb5bf38 --- /dev/null +++ b/test/downloader.test.js @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getFileNameFromResponse, tryGetFileNameFromResponse } from '../bin/downloader.js'; + +function createResponse(contentDisposition) { + return { + headers: { + get(name) { + return name.toLowerCase() === 'content-disposition' ? contentDisposition : null; + } + } + }; +} + +test('getFileNameFromResponse extracts the filename from content-disposition', () => { + const response = createResponse('attachment; filename=My+Archive.zip'); + + assert.equal(getFileNameFromResponse(response, '/Files'), 'My Archive.zip'); +}); + +test('getFileNameFromResponse throws when no file metadata exists', () => { + const response = createResponse(null); + + assert.throws( + () => getFileNameFromResponse(response, '/Files'), + /No files found in directory '\/Files'/ + ); +}); + +test('tryGetFileNameFromResponse returns null and stays silent by default', (t) => { + const response = createResponse(null); + const mockLog = t.mock.method(console, 'log', () => { }); + + assert.equal(tryGetFileNameFromResponse(response, '/Files'), null); + assert.equal(mockLog.mock.calls.length, 0); +}); + +test('tryGetFileNameFromResponse logs the error message in verbose mode', (t) => { + const response = createResponse(null); + const mockLog = t.mock.method(console, 'log', () => { }); + + assert.equal(tryGetFileNameFromResponse(response, '/Files', true), null); + assert.equal(mockLog.mock.calls.length, 1); + assert.match(String(mockLog.mock.calls[0].arguments[0]), /No files found in directory '\/Files'/); +}); diff --git a/test/env.test.js b/test/env.test.js new file mode 100644 index 0000000..c28c4b9 --- /dev/null +++ b/test/env.test.js @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { setConfigForTests } from '../bin/commands/config.js'; +import { parseHostInput, setupEnv } from '../bin/commands/env.js'; + +test.afterEach(() => { + setConfigForTests({}); +}); + +test('parseHostInput defaults to https when no protocol is provided', () => { + assert.deepEqual(parseHostInput('example.com'), { + protocol: 'https', + host: 'example.com' + }); +}); + +test('parseHostInput preserves an explicit protocol', () => { + assert.deepEqual(parseHostInput('http://example.com'), { + protocol: 'http', + host: 'example.com' + }); +}); + +test('parseHostInput rejects malformed host values with multiple protocol separators', () => { + assert.throws( + () => parseHostInput('https://example.com://admin'), + /Issues resolving host/ + ); +}); + +test('setupEnv prefers direct host arguments and defaults protocol to https', async () => { + setConfigForTests({ + env: { + saved: { + host: 'saved.example.com', + protocol: 'http' + } + }, + current: { + env: 'saved' + } + }); + + const env = await setupEnv({ + host: 'override.example.com' + }); + + assert.deepEqual(env, { + host: 'override.example.com', + protocol: 'https' + }); +}); + +test('setupEnv uses the explicit protocol when provided with a host override', async () => { + const env = await setupEnv({ + host: 'override.example.com', + protocol: 'http' + }); + + assert.deepEqual(env, { + host: 'override.example.com', + protocol: 'http' + }); +}); + +test('setupEnv resolves the requested environment from config', async () => { + setConfigForTests({ + env: { + dev: { + host: 'dev.example.com', + protocol: 'https' + }, + prod: { + host: 'prod.example.com', + protocol: 'http' + } + }, + current: { + env: 'dev' + } + }); + + const env = await setupEnv({ + env: 'prod' + }); + + assert.deepEqual(env, { + host: 'prod.example.com', + protocol: 'http' + }); +}); + +test('setupEnv falls back to the current environment and defaults a missing protocol', async () => { + const originalLog = console.log; + const logCalls = []; + console.log = (...args) => { + logCalls.push(args); + }; + + setConfigForTests({ + env: { + dev: { + host: 'dev.example.com' + } + }, + current: { + env: 'dev' + } + }); + + try { + const env = await setupEnv({}); + + assert.deepEqual(env, { + host: 'dev.example.com', + protocol: 'https' + }); + assert.equal(logCalls.length, 1); + assert.match(String(logCalls[0][0]), /Protocol for environment not set, defaulting to https/); + } finally { + console.log = originalLog; + } +}); + +test('setupEnv throws in json mode when no environment is configured', async () => { + setConfigForTests({}); + + await assert.rejects( + setupEnv({ + output: 'json' + }), + /Current environment not set, please set it/ + ); +}); + +test('setupEnv uses the interactive fallback when no environment is configured', async () => { + setConfigForTests({}); + const calls = []; + const originalLog = console.log; + console.log = () => {}; + + try { + const env = await setupEnv({}, null, { + interactiveEnvFn: async (argv, options) => { + calls.push({ argv, options }); + setConfigForTests({ + env: { + qa: { + host: 'qa.example.com', + protocol: 'https' + } + }, + current: { + env: 'qa' + } + }); + } + }); + + assert.deepEqual(env, { + host: 'qa.example.com', + protocol: 'https' + }); + assert.equal(calls.length, 1); + assert.deepEqual(Object.keys(calls[0].options), ['environment', 'interactive']); + } finally { + console.log = originalLog; + } +}); diff --git a/test/files.test.js b/test/files.test.js new file mode 100644 index 0000000..cbac51c --- /dev/null +++ b/test/files.test.js @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + getFilesOperation, + isFilePath, + prepareDownloadCommandData, + resolveFilePath, + wildcardToRegExp +} from '../bin/commands/files.js'; + +test('prepareDownloadCommandData uses directory download for recursive folder exports', () => { + const result = prepareDownloadCommandData('/Files', 'system/log', [], true, false); + + assert.equal(result.endpoint, 'DirectoryDownload'); + assert.deepEqual(result.data, { + DirectoryPath: '/Files', + ExcludeDirectories: ['system/log'] + }); +}); + +test('prepareDownloadCommandData uses file download for single-file exports', () => { + const result = prepareDownloadCommandData('/Files', '', ['/Files/logo.png'], false, true); + + assert.equal(result.endpoint, 'FileDownload'); + assert.deepEqual(result.data, { + DirectoryPath: '/Files', + ExcludeDirectories: [''], + Ids: ['/Files/logo.png'] + }); +}); + +test('isFilePath respects explicit overrides before extension detection', () => { + assert.equal(isFilePath({ asFile: true }, 'folder.with.dot'), true); + assert.equal(isFilePath({ asDirectory: true }, 'file.txt'), false); + assert.equal(isFilePath({}, 'file.txt'), true); + assert.equal(isFilePath({}, 'folder'), false); +}); + +test('wildcardToRegExp escapes regex characters and expands asterisks', () => { + const regex = wildcardToRegExp('plugin-*.dll'); + + assert.equal(regex.test('plugin-core.dll'), true); + assert.equal(regex.test('plugin-core.dll.bak'), false); + assert.equal(regex.test('plugin-(core).dll'), true); +}); + +test('resolveFilePath resolves wildcard matches from the target directory', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-files-test-')); + + try { + fs.writeFileSync(path.join(tempDir, 'plugin-core.dll'), ''); + fs.writeFileSync(path.join(tempDir, 'plugin-extra.nupkg'), ''); + + const resolved = resolveFilePath(path.join(tempDir, 'plugin-*.dll')); + + assert.equal(resolved, path.join(tempDir, 'plugin-core.dll')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolveFilePath throws when the wildcard finds no match', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-files-test-')); + + try { + assert.throws( + () => resolveFilePath(path.join(tempDir, '*.dll')), + /Could not find any files with the name/ + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('getFilesOperation reports the selected files subcommand', () => { + assert.equal(getFilesOperation({ list: true }), 'list'); + assert.equal(getFilesOperation({ export: true }), 'export'); + assert.equal(getFilesOperation({ import: true }), 'import'); + assert.equal(getFilesOperation({ delete: true }), 'delete'); + assert.equal(getFilesOperation({ copy: '/dest' }), 'copy'); + assert.equal(getFilesOperation({ move: '/dest' }), 'move'); + assert.equal(getFilesOperation({}), 'unknown'); +}); diff --git a/test/login.test.js b/test/login.test.js new file mode 100644 index 0000000..8b41ed8 --- /dev/null +++ b/test/login.test.js @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseCookies, resolveOAuthConfig, shouldUseOAuth } from '../bin/commands/login.js'; + +test('parseCookies returns an empty object when the header is missing', () => { + assert.deepEqual(parseCookies(), {}); +}); + +test('parseCookies normalizes the Dynamicweb cookie name and decodes the value', () => { + const cookies = parseCookies('Dynamicweb.Admin=abc%20123; Path=/'); + + assert.equal(cookies.user, 'abc 123'); + assert.equal(cookies.Path, '/'); +}); + +test('shouldUseOAuth honors explicit user auth override', () => { + assert.equal(shouldUseOAuth({ auth: 'user', oauth: true }, { + current: { authType: 'oauth_client_credentials' } + }), false); +}); + +test('shouldUseOAuth enables oauth from arguments or environment configuration', () => { + assert.equal(shouldUseOAuth({ oauth: true }), true); + assert.equal(shouldUseOAuth({}, { current: { authType: 'oauth_client_credentials' } }), true); + assert.equal(shouldUseOAuth({}, { auth: { type: 'oauth_client_credentials' } }), true); + assert.equal(shouldUseOAuth({}), false); +}); + +test('resolveOAuthConfig prefers explicit args over environment variables', () => { + process.env.TEST_CLI_CLIENT_ID = 'env-client-id'; + process.env.TEST_CLI_CLIENT_SECRET = 'env-client-secret'; + + try { + const config = resolveOAuthConfig({ + clientId: 'arg-client-id', + clientSecret: 'arg-client-secret', + clientIdEnv: 'TEST_CLI_CLIENT_ID', + clientSecretEnv: 'TEST_CLI_CLIENT_SECRET' + }); + + assert.deepEqual(config, { + clientId: 'arg-client-id', + clientSecret: 'arg-client-secret', + clientIdEnv: 'TEST_CLI_CLIENT_ID', + clientSecretEnv: 'TEST_CLI_CLIENT_SECRET' + }); + } finally { + delete process.env.TEST_CLI_CLIENT_ID; + delete process.env.TEST_CLI_CLIENT_SECRET; + } +}); + +test('resolveOAuthConfig falls back to configured env var names', () => { + process.env.TEST_CLI_CLIENT_ID = 'env-client-id'; + process.env.TEST_CLI_CLIENT_SECRET = 'env-client-secret'; + + try { + const config = resolveOAuthConfig({}, { + auth: { + clientIdEnv: 'TEST_CLI_CLIENT_ID', + clientSecretEnv: 'TEST_CLI_CLIENT_SECRET' + } + }); + + assert.deepEqual(config, { + clientId: 'env-client-id', + clientSecret: 'env-client-secret', + clientIdEnv: 'TEST_CLI_CLIENT_ID', + clientSecretEnv: 'TEST_CLI_CLIENT_SECRET' + }); + } finally { + delete process.env.TEST_CLI_CLIENT_ID; + delete process.env.TEST_CLI_CLIENT_SECRET; + } +}); + +test('resolveOAuthConfig throws when required credentials are missing', () => { + assert.throws( + () => resolveOAuthConfig({ clientIdEnv: 'MISSING_CLIENT_ID', clientSecret: 'secret' }), + /OAuth client ID not found/ + ); + + assert.throws( + () => resolveOAuthConfig({ clientId: 'client-id', clientSecretEnv: 'MISSING_CLIENT_SECRET' }), + /OAuth client secret not found/ + ); +}); diff --git a/test/query.test.js b/test/query.test.js new file mode 100644 index 0000000..3dc242a --- /dev/null +++ b/test/query.test.js @@ -0,0 +1,140 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildInteractiveQueryParams, + buildQueryParamsFromArgv, + extractQueryPropertyPrompts, + getFieldNameFromPropertyPrompt, + getQueryParams +} from '../bin/commands/query.js'; + +test('extractQueryPropertyPrompts maps properties fields to prompt labels', () => { + const prompts = extractQueryPropertyPrompts({ + model: { + properties: { + groups: [ + { + name: 'Properties', + fields: [ + { name: 'id', typeName: 'System.Int32' }, + { name: 'culture', typeName: 'System.String' } + ] + } + ] + } + } + }); + + assert.deepEqual(prompts, [ + 'id (System.Int32)', + 'culture (System.String)' + ]); +}); + +test('extractQueryPropertyPrompts returns an empty list when the Properties group is missing', () => { + const prompts = extractQueryPropertyPrompts({ + model: { + properties: { + groups: [ + { + name: 'Other', + fields: [ + { name: 'ignored', typeName: 'System.String' } + ] + } + ] + } + } + }); + + assert.deepEqual(prompts, []); +}); + +test('getFieldNameFromPropertyPrompt removes only the trailing type suffix', () => { + assert.equal(getFieldNameFromPropertyPrompt('id (System.Int32)'), 'id'); + assert.equal(getFieldNameFromPropertyPrompt('Category (Primary) (System.String)'), 'Category (Primary)'); + assert.equal(getFieldNameFromPropertyPrompt('plainField'), 'plainField'); +}); + +test('buildInteractiveQueryParams maps prompt answers back to field names and skips empty values', async () => { + const prompts = [ + 'id (System.Int32)', + 'Category (Primary) (System.String)', + 'culture (System.String)' + ]; + const answers = ['42', 'news', '']; + const seenMessages = []; + + const params = await buildInteractiveQueryParams(prompts, async ({ message }) => { + seenMessages.push(message); + return answers.shift(); + }); + + assert.deepEqual(seenMessages, prompts); + assert.deepEqual(params, { + id: '42', + 'Category (Primary)': 'news' + }); +}); + +test('buildQueryParamsFromArgv keeps only query-specific arguments', () => { + const params = buildQueryParamsFromArgv({ + query: 'GetItems', + host: 'example.com', + protocol: 'https', + interactive: true, + output: 'json', + id: 123, + culture: 'en-US' + }); + + assert.deepEqual(params, { + id: 123, + culture: 'en-US' + }); +}); + +test('getQueryParams uses filtered argv params in non-interactive mode', async () => { + const params = await getQueryParams(null, null, { + query: 'GetItems', + host: 'example.com', + id: 99, + pageSize: 10 + }, { + log() {} + }); + + assert.deepEqual(params, { + id: 99, + pageSize: 10 + }); +}); + +test('getQueryParams uses property prompts and prompt answers in interactive mode', async () => { + const outputCalls = []; + + const params = await getQueryParams(null, null, { + query: 'GetItems', + interactive: true + }, { + log(value) { + outputCalls.push(value); + } + }, { + getPropertiesFn: async () => [ + 'id (System.Int32)', + 'culture (System.String)' + ], + promptFn: async ({ message }) => message === 'id (System.Int32)' ? '77' : 'da-DK' + }); + + assert.deepEqual(params, { + id: '77', + culture: 'da-DK' + }); + assert.deepEqual(outputCalls, [ + 'The following properties will be requested:', + ['id (System.Int32)', 'culture (System.String)'] + ]); +}); diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..01783fe --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatBytes, formatElapsed } from '../bin/utils.js'; + +test('formatBytes formats zero bytes', () => { + assert.equal(formatBytes(0), '0 Bytes'); +}); + +test('formatBytes formats kilobytes and megabytes', () => { + assert.equal(formatBytes(1024), '1.00 KB'); + assert.equal(formatBytes(1536), '1.50 KB'); + assert.equal(formatBytes(1024 * 1024), '1.00 MB'); +}); + +test('formatBytes formats sub-kilobyte, gigabyte, and terabyte values', () => { + assert.equal(formatBytes(500), '500.00 Bytes'); + assert.equal(formatBytes(1023), '1023.00 Bytes'); + assert.equal(formatBytes(1024 ** 3), '1.00 GB'); + assert.equal(formatBytes(1024 ** 4), '1.00 TB'); +}); + +test('formatElapsed formats seconds, minutes, and hours', () => { + assert.equal(formatElapsed(999), '0s'); + assert.equal(formatElapsed(1000), '1s'); + assert.equal(formatElapsed(5000), '5s'); + assert.equal(formatElapsed(61_000), '1m 1s'); + assert.equal(formatElapsed(120_000), '2m 0s'); + assert.equal(formatElapsed(3_661_000), '1h 1m 1s'); +}); + +test('formatElapsed handles invalid inputs', () => { + assert.equal(formatElapsed(-1000), '-1s'); + assert.equal(formatElapsed(null), '0s'); + assert.equal(formatElapsed(undefined), 'NaNs'); +});